diff --git a/.solhintignore b/.solhintignore index fef4a8c415..40d4c1df62 100644 --- a/.solhintignore +++ b/.solhintignore @@ -5,6 +5,7 @@ node_modules/ **/contracts/routers \*\*/typechain-types utils/core-contracts/contracts/utils/SafeCast +storage.dump.sol # foundry lib \*\*/lib @@ -16,5 +17,3 @@ utils/core-contracts/contracts/utils/SafeCast utils/core-modules/contracts/modules/UpgradeModule.sol utils/core-contracts/contracts/ownership/Ownable.sol utils/core-contracts/contracts/ownership/OwnableStorage.sol - -storage.dump.sol diff --git a/.yarn/cache/@foundry-rs-easy-foundryup-npm-0.1.3-e3da122086-a653a11e67.zip b/.yarn/cache/@foundry-rs-easy-foundryup-npm-0.1.3-e3da122086-a653a11e67.zip new file mode 100644 index 0000000000..cbb6dcbff0 Binary files /dev/null and b/.yarn/cache/@foundry-rs-easy-foundryup-npm-0.1.3-e3da122086-a653a11e67.zip differ diff --git a/.yarn/cache/@foundry-rs-hardhat-anvil-npm-0.1.7-c167024ef2-148b5fdd30.zip b/.yarn/cache/@foundry-rs-hardhat-anvil-npm-0.1.7-c167024ef2-148b5fdd30.zip new file mode 100644 index 0000000000..9009d39b82 Binary files /dev/null and b/.yarn/cache/@foundry-rs-hardhat-anvil-npm-0.1.7-c167024ef2-148b5fdd30.zip differ diff --git a/.yarn/cache/@nomiclabs-hardhat-waffle-npm-2.0.6-3743b1a35f-55e5edd8dd.zip b/.yarn/cache/@nomiclabs-hardhat-waffle-npm-2.0.6-3743b1a35f-55e5edd8dd.zip new file mode 100644 index 0000000000..a655651953 Binary files /dev/null and b/.yarn/cache/@nomiclabs-hardhat-waffle-npm-2.0.6-3743b1a35f-55e5edd8dd.zip differ diff --git a/.yarn/cache/@types-bn.js-npm-5.1.1-346449981b-cf2c45833e.zip b/.yarn/cache/@types-bn.js-npm-5.1.1-346449981b-cf2c45833e.zip deleted file mode 100644 index d9299f97c4..0000000000 Binary files a/.yarn/cache/@types-bn.js-npm-5.1.1-346449981b-cf2c45833e.zip and /dev/null differ diff --git a/.yarn/cache/@types-bn.js-npm-5.1.5-c2195eccd3-9719330c86.zip b/.yarn/cache/@types-bn.js-npm-5.1.5-c2195eccd3-9719330c86.zip new file mode 100644 index 0000000000..25347ade6d Binary files /dev/null and b/.yarn/cache/@types-bn.js-npm-5.1.5-c2195eccd3-9719330c86.zip differ diff --git a/.yarn/cache/@types-chai-npm-4.3.10-4c80ae34e8-a52b2c603c.zip b/.yarn/cache/@types-chai-npm-4.3.10-4c80ae34e8-a52b2c603c.zip new file mode 100644 index 0000000000..7c5baaebd1 Binary files /dev/null and b/.yarn/cache/@types-chai-npm-4.3.10-4c80ae34e8-a52b2c603c.zip differ diff --git a/.yarn/cache/@types-sinon-chai-npm-3.2.12-e5e54ca97b-d906f2f766.zip b/.yarn/cache/@types-sinon-chai-npm-3.2.12-e5e54ca97b-d906f2f766.zip new file mode 100644 index 0000000000..b5ff263157 Binary files /dev/null and b/.yarn/cache/@types-sinon-chai-npm-3.2.12-e5e54ca97b-d906f2f766.zip differ diff --git a/.yarn/cache/@types-sinon-npm-17.0.1-324dcf05a5-3459e48abd.zip b/.yarn/cache/@types-sinon-npm-17.0.1-324dcf05a5-3459e48abd.zip new file mode 100644 index 0000000000..052c4ae4c5 Binary files /dev/null and b/.yarn/cache/@types-sinon-npm-17.0.1-324dcf05a5-3459e48abd.zip differ diff --git a/.yarn/cache/@types-sinonjs__fake-timers-npm-8.1.5-c35b400174-3a0b285fcb.zip b/.yarn/cache/@types-sinonjs__fake-timers-npm-8.1.5-c35b400174-3a0b285fcb.zip new file mode 100644 index 0000000000..316a4c4ac7 Binary files /dev/null and b/.yarn/cache/@types-sinonjs__fake-timers-npm-8.1.5-c35b400174-3a0b285fcb.zip differ diff --git a/.yarn/cache/@types-underscore-npm-1.11.14-7f5f341bf6-8dc0619698.zip b/.yarn/cache/@types-underscore-npm-1.11.14-7f5f341bf6-8dc0619698.zip new file mode 100644 index 0000000000..1db66cec91 Binary files /dev/null and b/.yarn/cache/@types-underscore-npm-1.11.14-7f5f341bf6-8dc0619698.zip differ diff --git a/.yarn/cache/@types-web3-npm-1.0.19-88bec75594-bf61a77d63.zip b/.yarn/cache/@types-web3-npm-1.0.19-88bec75594-bf61a77d63.zip new file mode 100644 index 0000000000..1856fbaf85 Binary files /dev/null and b/.yarn/cache/@types-web3-npm-1.0.19-88bec75594-bf61a77d63.zip differ diff --git a/.yarn/cache/get-port-npm-7.0.0-72b8a92f99-e9087f62d0.zip b/.yarn/cache/get-port-npm-7.0.0-72b8a92f99-e9087f62d0.zip new file mode 100644 index 0000000000..3d2ff5a437 Binary files /dev/null and b/.yarn/cache/get-port-npm-7.0.0-72b8a92f99-e9087f62d0.zip differ diff --git a/.yarn/cache/ts-interface-checker-npm-0.1.13-0c7b064494-9f7346b9e2.zip b/.yarn/cache/ts-interface-checker-npm-0.1.13-0c7b064494-9f7346b9e2.zip new file mode 100644 index 0000000000..be45589c80 Binary files /dev/null and b/.yarn/cache/ts-interface-checker-npm-0.1.13-0c7b064494-9f7346b9e2.zip differ diff --git a/protocol/governance/.env.sample b/protocol/governance/.env.sample deleted file mode 100644 index 36739d7ac1..0000000000 --- a/protocol/governance/.env.sample +++ /dev/null @@ -1,13 +0,0 @@ -# ------------------------------------------------------------------------------- -# Infura Provider -# ------------------------------------------------------------------------------- -INFURA_KEY= -# ------------------------------------------------------------------------------- - -# ------------------------------------------------------------------------------- -# Canon Package Publish -# ------------------------------------------------------------------------------- -CANNON_PUBLISHER_PRIVATE_KEY= -INFURA_IPFS_ID= -INFURA_IPFS_SECRET= -# ------------------------------------------------------------------------------- diff --git a/protocol/governance/.gitignore b/protocol/governance/.gitignore index d5b25d66f4..813a9c6988 100644 --- a/protocol/governance/.gitignore +++ b/protocol/governance/.gitignore @@ -1,4 +1,2 @@ -contracts/Router.sol -deployments/hardhat -deployments/local -deployments/localhost +contracts/generated +test/generated diff --git a/protocol/governance/.solcover.js b/protocol/governance/.solcover.js index e3a56f445b..12803804d3 100644 --- a/protocol/governance/.solcover.js +++ b/protocol/governance/.solcover.js @@ -1,4 +1,4 @@ module.exports = { ...require('@synthetixio/common-config/.solcover.js'), - skipFiles: ['mocks', 'Router.sol'], + skipFiles: ['mocks', 'generated'], }; diff --git a/protocol/governance/cannonfile.satellite.test.toml b/protocol/governance/cannonfile.satellite.test.toml new file mode 100644 index 0000000000..ce98d43fe9 --- /dev/null +++ b/protocol/governance/cannonfile.satellite.test.toml @@ -0,0 +1,6 @@ +name = "synthetix-governance" +version = "<%= package.version %>-test-sat" +include = ["cannonfile.satellite.toml", "tomls/ccip.test.toml"] + +[contract.SnapshotRecordMock] +artifact = "contracts/mocks/SnapshotRecordMock.sol:SnapshotRecordMock" diff --git a/protocol/governance/cannonfile.satellite.toml b/protocol/governance/cannonfile.satellite.toml new file mode 100644 index 0000000000..f4090d866f --- /dev/null +++ b/protocol/governance/cannonfile.satellite.toml @@ -0,0 +1,46 @@ +name = "synthetix-governance" +description = "On-chain voting for synthetix councils - Logic for satellite chains" +version = "<%= package.version %>" +preset = "satellite" +include = ["./tomls/proxy-base.toml", "./tomls/council-token.toml"] + +[setting.initial_epoch_index] +defaultValue = "0" + +[contract.AssociatedSystemsModule] +artifact = "contracts/modules/core/AssociatedSystemsModule.sol:AssociatedSystemsModule" + +[contract.CrossChainModule] +artifact = "contracts/modules/core/CrossChainModule.sol:CrossChainModule" + +[contract.ElectionModuleSatellite] +artifact = "contracts/modules/core/ElectionModuleSatellite.sol:ElectionModuleSatellite" + +[contract.CouncilTokenModule] +artifact = "contracts/modules/council-nft/CouncilTokenModule.sol:CouncilTokenModule" + +[contract.SnapshotVotePowerModule] +artifact = "contracts/modules/core/SnapshotVotePowerModule.sol:SnapshotVotePowerModule" + +[contract.CcipReceiverModule] +artifact = "contracts/modules/core/CcipReceiverModule.sol:CcipReceiverModule" + +[router.CoreRouter] +contracts = [ + "AssociatedSystemsModule", + "CrossChainModule", + "ElectionModuleSatellite", + "SnapshotVotePowerModule", + "InitialModuleBundle", + "CcipReceiverModule", + "CouncilTokenModule" +] + +[invoke.upgrade_core_proxy] +target = ["InitialProxy"] +from = "<%= settings.owner %>" +func = "upgradeTo" +args = ["<%= contracts.CoreRouter.address %>"] +factory.CoreProxy.abiOf = ["CoreRouter"] +factory.CoreProxy.event = "Upgraded" +factory.CoreProxy.arg = 0 diff --git a/protocol/governance/cannonfile.test.toml b/protocol/governance/cannonfile.test.toml new file mode 100644 index 0000000000..46c0af2e72 --- /dev/null +++ b/protocol/governance/cannonfile.test.toml @@ -0,0 +1,48 @@ +name = "synthetix-governance" +version = "<%= package.version %>-testable" +include = ["cannonfile.toml", "tomls/ccip.test.toml"] + +[contract.SnapshotRecordMock] +artifact = "contracts/mocks/SnapshotRecordMock.sol:SnapshotRecordMock" + +# Testable storage contracts +[contract.TestableCouncilStorage] +artifact = "contracts/generated/test/TestableCouncilStorage.sol:TestableCouncilStorage" + +[contract.TestableBallotStorage] +artifact = "contracts/generated/test/TestableBallotStorage.sol:TestableBallotStorage" + +[contract.TestableSnapshotVotePowerStorage] +artifact = "contracts/generated/test/TestableSnapshotVotePowerStorage.sol:TestableSnapshotVotePowerStorage" + +[contract.TestableElectionSettingsStorage] +artifact = "contracts/generated/test/TestableElectionSettingsStorage.sol:TestableElectionSettingsStorage" + +[contract.TestableElectionStorage] +artifact = "contracts/generated/test/TestableElectionStorage.sol:TestableElectionStorage" + +[contract.TestableEpochStorage] +artifact = "contracts/generated/test/TestableEpochStorage.sol:TestableEpochStorage" + +# Core Router + Testable modules +[router.TestRouter] +contracts = [ + # Extend Original Core Router + "CoreRouter", + # Testable storage contracts + "TestableCouncilStorage", + "TestableBallotStorage", + "TestableSnapshotVotePowerStorage", + "TestableElectionSettingsStorage", + "TestableElectionStorage", + "TestableEpochStorage" +] + +[invoke.upgrade_core_proxy] +target = ["InitialProxy"] +from = "<%= settings.owner %>" +func = "upgradeTo" +args = ["<%= contracts.TestRouter.address %>"] +factory.CoreProxy.abiOf = ["TestRouter"] +factory.CoreProxy.event = "Upgraded" +factory.CoreProxy.arg = 0 diff --git a/protocol/governance/cannonfile.toml b/protocol/governance/cannonfile.toml index 3ae91f82ca..f59fd94693 100644 --- a/protocol/governance/cannonfile.toml +++ b/protocol/governance/cannonfile.toml @@ -1,63 +1,58 @@ name = "synthetix-governance" description = "On-chain voting for synthetix councils" version = "<%= package.version %>" +preset = "main" +include = ["./tomls/proxy-base.toml", "./tomls/council-token.toml"] -[setting.salt] -defaultValue = "governance" - -# Deployment Owner, defaults to first hardhat account -[setting.owner] +[setting.initial_council_member] defaultValue = "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266" -[setting.council_token_name] -defaultValue = "Synthetix Governance Module" - -[setting.council_token_symbol] -defaultValue = "SNXGOV" +[setting.minimum_active_members] +defaultValue = "1" -[setting.init_council_member] -defaultValue = "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266" # can only specify 1 +[setting.initial_nomination_period_start_date] +defaultValue = "0" # defaults to "block.timestamp + administration_period_duration" when given "0" -[setting.epoch_start] +[setting.administration_period_duration] +defaultValue = "60" # days -[setting.epoch_duration] -defaultValue = "90" # days +[setting.nomination_period_duration] +defaultValue = "15" # days [setting.voting_period_duration] -defaultValue = "7" # days - -[contract.InitialModuleBundle] -artifact = "InitialModuleBundle" -create2 = true - -[contract.InitialProxy] -artifact = "contracts/Proxy.sol:Proxy" -args = ["<%= contracts.InitialModuleBundle.address %>", "<%= settings.owner %>"] -salt = "<%= settings.salt %>" -abiOf = ["InitialModuleBundle"] -create2 = true +defaultValue = "15" # days [contract.AssociatedSystemsModule] artifact = "contracts/modules/core/AssociatedSystemsModule.sol:AssociatedSystemsModule" -[contract.ElectionModule] -artifact = "ElectionModule" +[contract.CrossChainModule] +artifact = "contracts/modules/core/CrossChainModule.sol:CrossChainModule" [contract.ElectionInspectorModule] -artifact = "ElectionInspectorModule" +artifact = "contracts/modules/core/ElectionInspectorModule.sol:ElectionInspectorModule" + +[contract.ElectionModule] +artifact = "contracts/modules/core/ElectionModule.sol:ElectionModule" + +[contract.SnapshotVotePowerModule] +artifact = "contracts/modules/core/SnapshotVotePowerModule.sol:SnapshotVotePowerModule" [contract.CouncilTokenModule] -artifact = "CouncilTokenModule" +artifact = "contracts/modules/council-nft/CouncilTokenModule.sol:CouncilTokenModule" -[contract.DebtShareMock] -artifact = "DebtShareMock" +[contract.CcipReceiverModule] +artifact = "contracts/modules/core/CcipReceiverModule.sol:CcipReceiverModule" [router.CoreRouter] contracts = [ "AssociatedSystemsModule", - "ElectionModule", + "CrossChainModule", "ElectionInspectorModule", + "ElectionModule", + "SnapshotVotePowerModule", "InitialModuleBundle", + "CcipReceiverModule", + "CouncilTokenModule" ] [invoke.upgrade_core_proxy] @@ -69,37 +64,15 @@ factory.CoreProxy.abiOf = ["CoreRouter"] factory.CoreProxy.event = "Upgraded" factory.CoreProxy.arg = 0 -[router.CouncilTokenRouter] -contracts = ["CouncilTokenModule", "InitialModuleBundle"] - -[invoke.init_council_token] -target = ["CoreProxy"] -from = "<%= settings.owner %>" -func = "initOrUpgradeNft" -args = [ - "<%= formatBytes32String('councilToken') %>", - "Synthetix Governance Token", - "SNXGOV", - "https://synthetix.io", - "<%= contracts.CouncilTokenRouter.address %>" -] -depends = ["invoke.upgrade_core_proxy", "router.CouncilTokenRouter"] -factory.AccountProxy.abiOf = ["CouncilTokenRouter"] -factory.AccountProxy.event = "AssociatedSystemSet" -factory.AccountProxy.arg = 2 - [invoke.init_election_module] target = ["CoreProxy"] -func = "initOrUpgradeElectionModule(address[],uint8,uint64,uint64,uint64,address)" +func = "initOrUpdateElectionSettings(address[],uint8,uint64,uint64,uint64,uint64)" args = [ - ["<%= settings.init_council_member %>"], - "1", - "<%= settings.epoch_start %>", - "<%= settings.epoch_start + 86400 * settings.voting_period_duration %>", - "<%= settings.epoch_start + 86400 * settings.epoch_duration %>", - "<%= contracts.DebtShareMock.address %>", + ["<%= settings.initial_council_member %>"], + "<%= settings.minimum_active_members %>", + "<%= settings.initial_nomination_period_start_date %>", + "<%= settings.administration_period_duration %>", + "<%= settings.nomination_period_duration %>", + "<%= settings.voting_period_duration %>", ] from = "<%= settings.owner %>" - -[provision.trusted_multicall_forwarder] -source = "trusted-multicall-forwarder" diff --git a/protocol/governance/contracts/Proxy.sol b/protocol/governance/contracts/Proxy.sol index 43e551edd9..85bdf73cc3 100644 --- a/protocol/governance/contracts/Proxy.sol +++ b/protocol/governance/contracts/Proxy.sol @@ -4,9 +4,8 @@ pragma solidity >=0.8.11 <0.9.0; import {UUPSProxyWithOwner} from "@synthetixio/core-contracts/contracts/proxy/UUPSProxyWithOwner.sol"; contract Proxy is UUPSProxyWithOwner { - // solhint-disable-next-line no-empty-blocks constructor( address firstImplementation, address initialOwner - ) UUPSProxyWithOwner(firstImplementation, initialOwner) {} + ) UUPSProxyWithOwner(firstImplementation, initialOwner) {} // solhint-disable-line no-empty-blocks } diff --git a/protocol/governance/contracts/interfaces/ICouncilTokenModule.sol b/protocol/governance/contracts/interfaces/ICouncilTokenModule.sol index 6b2824e0a2..e7afe3e9e3 100644 --- a/protocol/governance/contracts/interfaces/ICouncilTokenModule.sol +++ b/protocol/governance/contracts/interfaces/ICouncilTokenModule.sol @@ -1,7 +1,7 @@ //SPDX-License-Identifier: MIT pragma solidity >=0.8.11 <0.9.0; -import "@synthetixio/core-modules/contracts/interfaces/INftModule.sol"; +import {INftModule} from "@synthetixio/core-modules/contracts/interfaces/INftModule.sol"; /** * @title Module with custom NFT logic for the account token. diff --git a/protocol/governance/contracts/interfaces/IDebtShare.sol b/protocol/governance/contracts/interfaces/IDebtShare.sol deleted file mode 100644 index 2148af8f5c..0000000000 --- a/protocol/governance/contracts/interfaces/IDebtShare.sol +++ /dev/null @@ -1,6 +0,0 @@ -//SPDX-License-Identifier: MIT -pragma solidity ^0.8.0; - -interface IDebtShare { - function balanceOfOnPeriod(address account, uint periodId) external view returns (uint); -} diff --git a/protocol/governance/contracts/interfaces/IElectionInspectorModule.sol b/protocol/governance/contracts/interfaces/IElectionInspectorModule.sol index e2ee8ef1d7..e41cc315fc 100644 --- a/protocol/governance/contracts/interfaces/IElectionInspectorModule.sol +++ b/protocol/governance/contracts/interfaces/IElectionInspectorModule.sol @@ -26,22 +26,13 @@ interface IElectionInspectorModule { /// @notice Returns a list of all nominated candidates in the given epoch function getNomineesAtEpoch(uint epochIndex) external view returns (address[] memory); - /// @notice Returns the ballot id that user voted on in the given election - function getBallotVotedAtEpoch(address user, uint epochIndex) external view returns (bytes32); - /// @notice Returns if user has voted in the given election - function hasVotedInEpoch(address user, uint epochIndex) external view returns (bool); - - /// @notice Returns the number of votes given to a particular ballot in a given epoch - function getBallotVotesInEpoch(bytes32 ballotId, uint epochIndex) external view returns (uint); - - /// @notice Returns the list of candidates that a particular ballot has in a given epoch - function getBallotCandidatesInEpoch( - bytes32 ballotId, + function hasVotedInEpoch( + address user, + uint chainId, uint epochIndex - ) external view returns (address[] memory); + ) external view returns (bool); - /// @notice Returns the number of votes a candidate received in a given epoch function getCandidateVotesInEpoch( address candidate, uint epochIndex diff --git a/protocol/governance/contracts/interfaces/IElectionModule.sol b/protocol/governance/contracts/interfaces/IElectionModule.sol index fe2b2f09a3..6e089f368b 100644 --- a/protocol/governance/contracts/interfaces/IElectionModule.sol +++ b/protocol/governance/contracts/interfaces/IElectionModule.sol @@ -1,24 +1,63 @@ //SPDX-License-Identifier: MIT pragma solidity ^0.8.0; +import {IElectionModuleSatellite} from "./IElectionModuleSatellite.sol"; +import {ElectionSettings} from "../storage/ElectionSettings.sol"; +import {Epoch} from "../storage/Epoch.sol"; +import {Ballot} from "../storage/Ballot.sol"; + /// @title Module for electing a council, represented by a set of NFT holders -interface IElectionModule { +interface IElectionModule is IElectionModuleSatellite { + error AlreadyNominated(); + error ElectionAlreadyEvaluated(); + error ElectionNotEvaluated(); + error NotNominated(); + error NoCandidates(); + error DuplicateCandidates(address duplicatedCandidate); + error TooManyMembers(); + + event ElectionModuleInitialized(); + event EpochStarted(uint256 indexed epochId); + event EpochScheduleUpdated(uint64 indexed epochId, uint64 startDate, uint64 endDate); + event EmergencyElectionStarted(uint256 indexed epochId); + event CandidateNominated(address indexed candidate, uint256 indexed epochId); + event NominationWithdrawn(address indexed candidate, uint256 indexed epochId); + event VoteRecorded( + address indexed voter, + uint256 indexed chainId, + uint256 indexed epochId, + uint256 votingPower, + address[] candidates + ); + + event VoteWithdrawn( + address indexed voter, + uint256 indexed chainId, + uint256 indexed epochId, + address[] candidates + ); + + event ElectionBatchEvaluated( + uint256 indexed epochId, + uint256 numEvaluatedBallots, + uint256 totalBallots + ); + event ElectionEvaluated(uint256 indexed epochId, uint256 ballotCount); + // --------------------------------------- // Initialization // --------------------------------------- - /// @notice Initializes the module and immediately starts the first epoch - function initOrUpgradeElectionModule( - address[] memory firstCouncil, + /// @notice Initialises the module and immediately starts the first epoch + function initOrUpdateElectionSettings( + address[] memory initialCouncil, uint8 minimumActiveMembers, - uint64 nominationPeriodStartDate, - uint64 votingPeriodStartDate, - uint64 epochEndDate + uint64 initialNominationPeriodStartDate, + uint64 administrationPeriodDuration, + uint64 nominationPeriodDuration, + uint64 votingPeriodDuration ) external; - /// @notice Shows whether the module has been initialized - function isElectionModuleInitialized() external view returns (bool); - // --------------------------------------- // Owner write functions // --------------------------------------- @@ -30,34 +69,18 @@ interface IElectionModule { uint64 newEpochEndDate ) external; - /// @notice Adjusts the current epoch schedule requiring that the current period remains Administration - function modifyEpochSchedule( - uint64 newNominationPeriodStartDate, - uint64 newVotingPeriodStartDate, - uint64 newEpochEndDate - ) external; - - /// @notice Determines minimum values for epoch schedule adjustments - function setMinEpochDurations( - uint64 newMinNominationPeriodDuration, - uint64 newMinVotingPeriodDuration, - uint64 newMinEpochDuration + /// @notice Adjust settings that will be used on next epoch + function setNextElectionSettings( + uint8 epochSeatCount, + uint8 minimumActiveMembers, + uint64 epochDuration, + uint64 nominationPeriodDuration, + uint64 votingPeriodDuration, + uint64 maxDateAdjustmentTolerance ) external; - /// @notice Determines adjustment size for tweakEpochSchedule - function setMaxDateAdjustmentTolerance(uint64 newMaxDateAdjustmentTolerance) external; - - /// @notice Determines batch size when evaluate() is called with numBallots = 0 - function setDefaultBallotEvaluationBatchSize(uint newDefaultBallotEvaluationBatchSize) external; - - /// @notice Determines the number of council members in the next epoch - function setNextEpochSeatCount(uint8 newSeatCount) external; - - /// @notice Determines the minimum number of council members before triggering an emergency election - function setMinimumActiveMembers(uint8 newMinimumActiveMembers) external; - /// @notice Allows the owner to remove one or more council members, triggering an election if a threshold is met - function dismissMembers(address[] calldata members) external; + function dismissMembers(address[] calldata members) external payable; // --------------------------------------- // User write functions @@ -69,61 +92,50 @@ interface IElectionModule { /// @notice Self-withdrawal of nominations during the Nomination period function withdrawNomination() external; - /// @notice Allows anyone with vote power to vote on nominated candidates during the Voting period - function cast(address[] calldata candidates) external; + /// @dev Internal voting logic, receiving end of CCIP voting + function _recvCast( + uint256 epochIndex, + address voter, + uint256 votingPower, + uint256 chainId, + address[] calldata candidates, + uint256[] calldata amounts + ) external; - /// @notice Allows votes to be withdraw - function withdrawVote() external; + function _recvWithdrawVote( + uint256 epochIndex, + address voter, + uint256 chainId, + address[] calldata candidates + ) external; /// @notice Processes ballots in batches during the Evaluation period (after epochEndDate) - function evaluate(uint numBallots) external; + function evaluate(uint256 numBallots) external; /// @notice Shuffles NFTs and resolves an election after it has been evaluated - function resolve() external; + function resolve() external payable; // --------------------------------------- // View functions // --------------------------------------- - /// @notice Exposes minimum durations required when adjusting epoch schedules - function getMinEpochDurations() - external - view - returns ( - uint64 minNominationPeriodDuration, - uint64 minVotingPeriodDuration, - uint64 minEpochDuration - ); - - /// @notice Exposes maximum size of adjustments when calling tweakEpochSchedule - function getMaxDateAdjustmenTolerance() external view returns (uint64); - - /// @notice Shows the default batch size when calling evaluate() with numBallots = 0 - function getDefaultBallotEvaluationBatchSize() external view returns (uint); + /// @notice Shows the current epoch schedule dates + function getEpochSchedule() external view returns (Epoch.Data memory epoch); - /// @notice Shows the number of council members that the next epoch will have - function getNextEpochSeatCount() external view returns (uint8); + /// @notice Shows the settings for the current election + function getElectionSettings() external view returns (ElectionSettings.Data memory settings); - /// @notice Returns the minimum active members that the council needs to avoid an emergency election - function getMinimumActiveMembers() external view returns (uint8); + /// @notice Shows the settings for the next election + function getNextElectionSettings() + external + view + returns (ElectionSettings.Data memory settings); /// @notice Returns the index of the current epoch. The first epoch's index is 1 - function getEpochIndex() external view returns (uint); - - /// @notice Returns the date in which the current epoch started - function getEpochStartDate() external view returns (uint64); - - /// @notice Returns the date in which the current epoch will end - function getEpochEndDate() external view returns (uint64); - - /// @notice Returns the date in which the Nomination period in the current epoch will start - function getNominationPeriodStartDate() external view returns (uint64); - - /// @notice Returns the date in which the Voting period in the current epoch will start - function getVotingPeriodStartDate() external view returns (uint64); + function getEpochIndex() external view returns (uint256); /// @notice Returns the current period type: Administration, Nomination, Voting, Evaluation - function getCurrentPeriod() external view returns (uint); + function getCurrentPeriod() external view returns (uint256); /// @notice Shows if a candidate has been nominated in the current epoch function isNominated(address candidate) external view returns (bool); @@ -131,29 +143,34 @@ interface IElectionModule { /// @notice Returns a list of all nominated candidates in the current epoch function getNominees() external view returns (address[] memory); - /// @notice Hashes a list of candidates (used for identifying and storing ballots) - function calculateBallotId(address[] calldata candidates) external pure returns (bytes32); - - /// @notice Returns the ballot id that user voted on in the current election - function getBallotVoted(address user) external view returns (bytes32); - /// @notice Returns if user has voted in the current election - function hasVoted(address user) external view returns (bool); + function hasVoted(address user, uint256 chainId) external view returns (bool); /// @notice Returns the vote power of user in the current election - function getVotePower(address user) external view returns (uint); - - /// @notice Returns the number of votes given to a particular ballot - function getBallotVotes(bytes32 ballotId) external view returns (uint); + function getVotePower( + address user, + uint256 chainId, + uint256 electionId + ) external view returns (uint256); /// @notice Returns the list of candidates that a particular ballot has - function getBallotCandidates(bytes32 ballotId) external view returns (address[] memory); + function getBallotCandidates( + address voter, + uint256 chainId, + uint256 electionId + ) external view returns (address[] memory); /// @notice Returns whether all ballots in the current election have been counted function isElectionEvaluated() external view returns (bool); + function getBallot( + address voter, + uint256 chainId, + uint256 electionId + ) external pure returns (Ballot.Data memory); + /// @notice Returns the number of votes a candidate received. Requires the election to be partially or totally evaluated - function getCandidateVotes(address candidate) external view returns (uint); + function getCandidateVotes(address candidate) external view returns (uint256); /// @notice Returns the winners of the current election. Requires the election to be partially or totally evaluated function getElectionWinners() external view returns (address[] memory); diff --git a/protocol/governance/contracts/interfaces/IElectionModuleSatellite.sol b/protocol/governance/contracts/interfaces/IElectionModuleSatellite.sol new file mode 100644 index 0000000000..8fead640fd --- /dev/null +++ b/protocol/governance/contracts/interfaces/IElectionModuleSatellite.sol @@ -0,0 +1,49 @@ +//SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +/// @title Election module council with minimal logic to be deployed on Satellite chains +interface IElectionModuleSatellite { + error NoVotingPower(address sender, uint256 currentEpoch); + + event CouncilMembersDismissed(address[] dismissedMembers, uint256 epochId); + + /// @dev Initialize first epoch of the current election module. Can only be called once. + function _recvInitElectionModuleSatellite( + uint256 epochIndex, + uint64 epochStartDate, + uint64 nominationPeriodStartDate, + uint64 votingPeriodStartDate, + uint64 epochEndDate, + address[] calldata councilMembers + ) external; + + /// @notice Shows whether the module has been initialized + function isElectionModuleInitialized() external view returns (bool); + + /// @notice Allows anyone with vote power to vote on nominated candidates during the Voting period + function cast(address[] calldata candidates, uint256[] calldata amounts) external payable; + + /// @notice Allows to withdraw a casted vote on the current network. + function withdrawVote(address[] calldata candidates) external payable; + + /// @dev Burn the council tokens from the given members; receiving end of CCIP members dismissal + function _recvDismissMembers(address[] calldata membersToDismiss, uint256 epochIndex) external; + + /// @dev Tweak the epoch dates to the given ones, without validation because we assume that it was started from mothership + function _recvTweakEpochSchedule( + uint256 epochIndex, + uint64 nominationPeriodStartDate, + uint64 votingPeriodStartDate, + uint64 epochEndDate + ) external; + + /// @dev Burn current epoch council tokens and assign new ones, setup epoch dates. Receiving end of CCIP epoch resolution. + function _recvResolve( + uint256 epochIndex, + uint64 epochStartDate, + uint64 nominationPeriodStartDate, + uint64 votingPeriodStartDate, + uint64 epochEndDate, + address[] calldata councilMembers + ) external; +} diff --git a/protocol/governance/contracts/interfaces/ISnapshotVotePowerModule.sol b/protocol/governance/contracts/interfaces/ISnapshotVotePowerModule.sol new file mode 100644 index 0000000000..1ba5e94ba6 --- /dev/null +++ b/protocol/governance/contracts/interfaces/ISnapshotVotePowerModule.sol @@ -0,0 +1,19 @@ +//SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +interface ISnapshotVotePowerModule { + error SnapshotAlreadyTaken(uint128 snapshotId); + error BallotAlreadyPrepared(address voter, uint256 electionId); + error SnapshotNotTaken(address snapshotContract, uint128 electionId); + error NoPower(uint256, address); + error InvalidSnapshotContract(); + + function setSnapshotContract(address snapshotContract, bool enabled) external; + + function takeVotePowerSnapshot(address snapshotContract) external returns (uint128 snapshotId); + + function prepareBallotWithSnapshot( + address snapshotContract, + address voter + ) external returns (uint256 power); +} diff --git a/protocol/governance/contracts/interfaces/ISynthetixElectionModule.sol b/protocol/governance/contracts/interfaces/ISynthetixElectionModule.sol deleted file mode 100644 index 3316e4c235..0000000000 --- a/protocol/governance/contracts/interfaces/ISynthetixElectionModule.sol +++ /dev/null @@ -1,65 +0,0 @@ -//SPDX-License-Identifier: MIT -pragma solidity ^0.8.0; - -import {IElectionModule as IBaseElectionModule} from "./IElectionModule.sol"; - -interface ISynthetixElectionModule is IBaseElectionModule { - /// @notice Initializes the module and immediately starts the first epoch - function initOrUpgradeElectionModule( - address[] memory firstCouncil, - uint8 minimumActiveMembers, - uint64 nominationPeriodStartDate, - uint64 votingPeriodStartDate, - uint64 epochEndDate, - address debtShareContract - ) external; - - // --------------------------------------- - // Debt shares - // --------------------------------------- - - /// @notice Sets the Synthetix v2 DebtShare contract that determines vote power - function setDebtShareContract(address newDebtShareContractAddress) external; - - /// @notice Returns the Synthetix v2 DebtShare contract that determines vote power - function getDebtShareContract() external view returns (address); - - /// @notice Sets the Synthetix v2 DebtShare snapshot that determines vote power for this epoch - function setDebtShareSnapshotId(uint snapshotId) external; - - /// @notice Returns the Synthetix v2 DebtShare snapshot id set for this epoch - function getDebtShareSnapshotId() external view returns (uint); - - /// @notice Returns the Synthetix v2 debt share for the provided address, at this epoch's snapshot - function getDebtShare(address user) external view returns (uint); - - // --------------------------------------- - // Cross chain debt shares - // --------------------------------------- - - /// @notice Allows the system owner to declare a merkle root for user debt shares on other chains for this epoch - function setCrossChainDebtShareMerkleRoot(bytes32 merkleRoot, uint blocknumber) external; - - /// @notice Returns the current epoch's merkle root for user debt shares on other chains - function getCrossChainDebtShareMerkleRoot() external view returns (bytes32); - - /// @notice Returns the current epoch's merkle root block number - function getCrossChainDebtShareMerkleRootBlockNumber() external view returns (uint); - - /// @notice Allows users to declare their Synthetix v2 debt shares on other chains - function declareCrossChainDebtShare( - address account, - uint256 debtShare, - bytes32[] calldata merkleProof - ) external; - - /// @notice Returns the Synthetix v2 debt shares for the provided address, at this epoch's snapshot, in other chains - function getDeclaredCrossChainDebtShare(address account) external view returns (uint); - - /// @notice Declares cross chain debt shares and casts a vote - function declareAndCast( - uint256 debtShare, - bytes32[] calldata merkleProof, - address[] calldata candidates - ) external; -} diff --git a/protocol/governance/contracts/interfaces/external/ISnapshotRecord.sol b/protocol/governance/contracts/interfaces/external/ISnapshotRecord.sol new file mode 100644 index 0000000000..352bce37b4 --- /dev/null +++ b/protocol/governance/contracts/interfaces/external/ISnapshotRecord.sol @@ -0,0 +1,10 @@ +//SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +interface ISnapshotRecord { + function balanceOfOnPeriod(address account, uint128 periodId) external view returns (uint); + + function totalSupplyOnPeriod(uint128 periodId) external view returns (uint); + + function takeSnapshot(uint128 id) external; +} diff --git a/protocol/governance/contracts/mocks/CcipRouterMock.sol b/protocol/governance/contracts/mocks/CcipRouterMock.sol new file mode 100644 index 0000000000..0bcc45a8cf --- /dev/null +++ b/protocol/governance/contracts/mocks/CcipRouterMock.sol @@ -0,0 +1,9 @@ +//SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {CcipRouterMock as CcipRouterMockBase} from "@synthetixio/core-modules/contracts/mocks/CcipRouterMock.sol"; + +// solhint-disable-next-line no-empty-blocks +contract CcipRouterMock is CcipRouterMockBase { + +} diff --git a/protocol/governance/contracts/mocks/DebtShareMock.sol b/protocol/governance/contracts/mocks/DebtShareMock.sol deleted file mode 100644 index bfe167715b..0000000000 --- a/protocol/governance/contracts/mocks/DebtShareMock.sol +++ /dev/null @@ -1,29 +0,0 @@ -//SPDX-License-Identifier: MIT -pragma solidity ^0.8.0; - -import "../interfaces/IDebtShare.sol"; - -contract DebtShareMock is IDebtShare { - struct Period { - mapping(address => uint) balances; - } - - mapping(uint128 => Period) private _periods; - - function setBalanceOfOnPeriod(address user, uint balance, uint periodId) external { - // solhint-disable-next-line numcast/safe-cast - Period storage period = _periods[uint128(periodId)]; - - period.balances[user] = balance; - } - - function balanceOfOnPeriod( - address user, - uint periodId - ) external view virtual override returns (uint) { - // solhint-disable-next-line numcast/safe-cast - Period storage period = _periods[uint128(periodId)]; - - return period.balances[user]; - } -} diff --git a/protocol/governance/contracts/mocks/SnapshotRecordMock.sol b/protocol/governance/contracts/mocks/SnapshotRecordMock.sol new file mode 100644 index 0000000000..cd76b74304 --- /dev/null +++ b/protocol/governance/contracts/mocks/SnapshotRecordMock.sol @@ -0,0 +1,42 @@ +//SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {ISnapshotRecord} from "../interfaces/external/ISnapshotRecord.sol"; + +contract SnapshotRecordMock is ISnapshotRecord { + struct Period { + mapping(address => uint) balances; + } + + mapping(uint128 => Period) private _periods; + mapping(uint128 => uint) private _totalSupplyOnPeriod; + + function setBalanceOfOnPeriod(address user, uint balance, uint periodId) external { + // solhint-disable-next-line numcast/safe-cast + Period storage period = _periods[uint128(periodId)]; + + period.balances[user] = balance; + } + + function balanceOfOnPeriod( + address user, + uint128 periodId + ) external view override returns (uint) { + Period storage period = _periods[periodId]; + + return period.balances[user]; + } + + // solhint-disable-next-line no-empty-blocks + function takeSnapshot(uint128 snapshotId) external override { + // do nothing + } + + function setTotalSupplyOnPeriod(uint128 snapshotId, uint totalSupply) external { + _totalSupplyOnPeriod[snapshotId] = totalSupply; + } + + function totalSupplyOnPeriod(uint128) external pure override returns (uint) { + return 0; + } +} diff --git a/protocol/governance/contracts/modules/core/BaseElectionModule.sol b/protocol/governance/contracts/modules/core/BaseElectionModule.sol deleted file mode 100644 index 49b931b975..0000000000 --- a/protocol/governance/contracts/modules/core/BaseElectionModule.sol +++ /dev/null @@ -1,440 +0,0 @@ -//SPDX-License-Identifier: MIT -pragma solidity ^0.8.0; - -import "@synthetixio/core-contracts/contracts/utils/ERC2771Context.sol"; -import "@synthetixio/core-contracts/contracts/errors/InitError.sol"; -import "@synthetixio/core-contracts/contracts/initializable/InitializableMixin.sol"; -import "@synthetixio/core-contracts/contracts/utils/SafeCast.sol"; -import "@synthetixio/core-contracts/contracts/ownership/OwnableStorage.sol"; -import "../../interfaces/IElectionModule.sol"; -import "../../submodules/election/ElectionSchedule.sol"; -import "../../submodules/election/ElectionCredentials.sol"; -import "../../submodules/election/ElectionVotes.sol"; -import "../../submodules/election/ElectionTally.sol"; - -contract BaseElectionModule is - IElectionModule, - ElectionSchedule, - ElectionCredentials, - ElectionVotes, - ElectionTally, - InitializableMixin -{ - using SetUtil for SetUtil.AddressSet; - using Council for Council.Data; - using SafeCastU256 for uint256; - - function initOrUpgradeElectionModule( - address[] memory firstCouncil, - uint8 minimumActiveMembers, - uint64 nominationPeriodStartDate, - uint64 votingPeriodStartDate, - uint64 epochEndDate - ) external virtual override onlyIfNotInitialized { - OwnableStorage.onlyOwner(); - - _initOrUpgradeElectionModule( - firstCouncil, - minimumActiveMembers, - nominationPeriodStartDate, - votingPeriodStartDate, - epochEndDate - ); - } - - function _initOrUpgradeElectionModule( - address[] memory firstCouncil, - uint8 minimumActiveMembers, - uint64 nominationPeriodStartDate, - uint64 votingPeriodStartDate, - uint64 epochEndDate - ) internal { - Council.Data storage store = Council.load(); - // solhint-disable-next-line numcast/safe-cast - uint8 seatCount = uint8(firstCouncil.length); - if (minimumActiveMembers == 0 || minimumActiveMembers > seatCount) { - revert InvalidMinimumActiveMembers(); - } - - ElectionSettings.Data storage settings = store.nextElectionSettings; - settings.minNominationPeriodDuration = 2 days; - settings.minVotingPeriodDuration = 2 days; - settings.minEpochDuration = 7 days; - settings.maxDateAdjustmentTolerance = 7 days; - // solhint-disable-next-line numcast/safe-cast - settings.nextEpochSeatCount = uint8(firstCouncil.length); - settings.minimumActiveMembers = minimumActiveMembers; - settings.defaultBallotEvaluationBatchSize = 500; - - store.newElection(); - - Epoch.Data storage firstEpoch = store.getCurrentElection().epoch; - uint64 epochStartDate = block.timestamp.to64(); - _configureEpochSchedule( - firstEpoch, - epochStartDate, - nominationPeriodStartDate, - votingPeriodStartDate, - epochEndDate - ); - - _addCouncilMembers(firstCouncil, 0); - - store.initialized = true; - - emit ElectionModuleInitialized(); - emit EpochStarted(0); - } - - function isElectionModuleInitialized() public view override returns (bool) { - return _isInitialized(); - } - - function _isInitialized() internal view override returns (bool) { - return Council.load().initialized; - } - - function tweakEpochSchedule( - uint64 newNominationPeriodStartDate, - uint64 newVotingPeriodStartDate, - uint64 newEpochEndDate - ) external override onlyInPeriod(Council.ElectionPeriod.Administration) { - OwnableStorage.onlyOwner(); - _adjustEpochSchedule( - Council.load().getCurrentElection().epoch, - newNominationPeriodStartDate, - newVotingPeriodStartDate, - newEpochEndDate, - true /*ensureChangesAreSmall = true*/ - ); - - emit EpochScheduleUpdated( - newNominationPeriodStartDate, - newVotingPeriodStartDate, - newEpochEndDate - ); - } - - function modifyEpochSchedule( - uint64 newNominationPeriodStartDate, - uint64 newVotingPeriodStartDate, - uint64 newEpochEndDate - ) external override onlyInPeriod(Council.ElectionPeriod.Administration) { - OwnableStorage.onlyOwner(); - _adjustEpochSchedule( - Council.load().getCurrentElection().epoch, - newNominationPeriodStartDate, - newVotingPeriodStartDate, - newEpochEndDate, - false /*!ensureChangesAreSmall = false*/ - ); - - emit EpochScheduleUpdated( - newNominationPeriodStartDate, - newVotingPeriodStartDate, - newEpochEndDate - ); - } - - function setMinEpochDurations( - uint64 newMinNominationPeriodDuration, - uint64 newMinVotingPeriodDuration, - uint64 newMinEpochDuration - ) external override { - OwnableStorage.onlyOwner(); - _setMinEpochDurations( - newMinNominationPeriodDuration, - newMinVotingPeriodDuration, - newMinEpochDuration - ); - - emit MinimumEpochDurationsChanged( - newMinNominationPeriodDuration, - newMinVotingPeriodDuration, - newMinEpochDuration - ); - } - - function setMaxDateAdjustmentTolerance(uint64 newMaxDateAdjustmentTolerance) external override { - OwnableStorage.onlyOwner(); - if (newMaxDateAdjustmentTolerance == 0) revert InvalidElectionSettings(); - - Council - .load() - .nextElectionSettings - .maxDateAdjustmentTolerance = newMaxDateAdjustmentTolerance; - - emit MaxDateAdjustmentToleranceChanged(newMaxDateAdjustmentTolerance); - } - - function setDefaultBallotEvaluationBatchSize( - uint newDefaultBallotEvaluationBatchSize - ) external override { - OwnableStorage.onlyOwner(); - if (newDefaultBallotEvaluationBatchSize == 0) revert InvalidElectionSettings(); - - Council - .load() - .nextElectionSettings - .defaultBallotEvaluationBatchSize = newDefaultBallotEvaluationBatchSize; - - emit DefaultBallotEvaluationBatchSizeChanged(newDefaultBallotEvaluationBatchSize); - } - - function setNextEpochSeatCount( - uint8 newSeatCount - ) external override onlyInPeriod(Council.ElectionPeriod.Administration) { - OwnableStorage.onlyOwner(); - if (newSeatCount == 0) revert InvalidElectionSettings(); - - Council.load().nextElectionSettings.nextEpochSeatCount = newSeatCount; - - emit NextEpochSeatCountChanged(newSeatCount); - } - - function setMinimumActiveMembers(uint8 newMinimumActiveMembers) external override { - OwnableStorage.onlyOwner(); - if (newMinimumActiveMembers == 0) revert InvalidMinimumActiveMembers(); - - Council.load().nextElectionSettings.minimumActiveMembers = newMinimumActiveMembers; - - emit MinimumActiveMembersChanged(newMinimumActiveMembers); - } - - function dismissMembers(address[] calldata membersToDismiss) external override { - OwnableStorage.onlyOwner(); - - uint epochIndex = Council.load().lastElectionId; - - _removeCouncilMembers(membersToDismiss, epochIndex); - - emit CouncilMembersDismissed(membersToDismiss, epochIndex); - - // Don't immediately jump to an election if the council still has enough members - if (Council.load().getCurrentPeriod() != Council.ElectionPeriod.Administration) return; - if ( - Council.load().councilMembers.length() >= - Council.load().nextElectionSettings.minimumActiveMembers - ) return; - - _jumpToNominationPeriod(); - - emit EmergencyElectionStarted(epochIndex); - } - - function nominate() public virtual override onlyInPeriod(Council.ElectionPeriod.Nomination) { - SetUtil.AddressSet storage nominees = Council.load().getCurrentElection().nominees; - - if (nominees.contains(ERC2771Context._msgSender())) revert AlreadyNominated(); - - nominees.add(ERC2771Context._msgSender()); - - emit CandidateNominated(ERC2771Context._msgSender(), Council.load().lastElectionId); - } - - function withdrawNomination() - external - override - onlyInPeriod(Council.ElectionPeriod.Nomination) - { - SetUtil.AddressSet storage nominees = Council.load().getCurrentElection().nominees; - - if (!nominees.contains(ERC2771Context._msgSender())) revert NotNominated(); - - nominees.remove(ERC2771Context._msgSender()); - - emit NominationWithdrawn(ERC2771Context._msgSender(), Council.load().lastElectionId); - } - - /// @dev ElectionVotes needs to be extended to specify what determines voting power - function cast( - address[] calldata candidates - ) public virtual override onlyInPeriod(Council.ElectionPeriod.Vote) { - uint votePower = _getVotePower(ERC2771Context._msgSender()); - - if (votePower == 0) revert NoVotePower(); - - _validateCandidates(candidates); - - bytes32 ballotId; - - uint epochIndex = Council.load().lastElectionId; - - if (hasVoted(ERC2771Context._msgSender())) { - _withdrawCastedVote(ERC2771Context._msgSender(), epochIndex); - } - - ballotId = _recordVote(ERC2771Context._msgSender(), votePower, candidates); - - emit VoteRecorded(ERC2771Context._msgSender(), ballotId, epochIndex, votePower); - } - - function withdrawVote() external override onlyInPeriod(Council.ElectionPeriod.Vote) { - if (!hasVoted(ERC2771Context._msgSender())) { - revert VoteNotCasted(); - } - - _withdrawCastedVote(ERC2771Context._msgSender(), Council.load().lastElectionId); - } - - /// @dev ElectionTally needs to be extended to specify how votes are counted - function evaluate( - uint numBallots - ) external override onlyInPeriod(Council.ElectionPeriod.Evaluation) { - Election.Data storage election = Council.load().getCurrentElection(); - - if (election.evaluated) revert ElectionAlreadyEvaluated(); - - _evaluateNextBallotBatch(numBallots); - - uint currentEpochIndex = Council.load().lastElectionId; - - uint totalBallots = election.ballotIds.length; - if (election.numEvaluatedBallots < totalBallots) { - emit ElectionBatchEvaluated( - currentEpochIndex, - election.numEvaluatedBallots, - totalBallots - ); - } else { - election.evaluated = true; - - emit ElectionEvaluated(currentEpochIndex, totalBallots); - } - } - - /// @dev Burns previous NFTs and mints new ones - function resolve() external override onlyInPeriod(Council.ElectionPeriod.Evaluation) { - Election.Data storage election = Council.load().getCurrentElection(); - - if (!election.evaluated) revert ElectionNotEvaluated(); - - uint newEpochIndex = Council.load().lastElectionId + 1; - - _removeAllCouncilMembers(newEpochIndex); - _addCouncilMembers(election.winners.values(), newEpochIndex); - - election.resolved = true; - - Council.load().newElection(); - _copyScheduleFromPreviousEpoch(); - - emit EpochStarted(newEpochIndex); - } - - function getMinEpochDurations() - external - view - override - returns ( - uint64 minNominationPeriodDuration, - uint64 minVotingPeriodDuration, - uint64 minEpochDuration - ) - { - ElectionSettings.Data storage settings = Council.load().nextElectionSettings; - - return ( - settings.minNominationPeriodDuration, - settings.minVotingPeriodDuration, - settings.minEpochDuration - ); - } - - function getMaxDateAdjustmenTolerance() external view override returns (uint64) { - return Council.load().nextElectionSettings.maxDateAdjustmentTolerance; - } - - function getDefaultBallotEvaluationBatchSize() external view override returns (uint) { - return Council.load().nextElectionSettings.defaultBallotEvaluationBatchSize; - } - - function getNextEpochSeatCount() external view override returns (uint8) { - return Council.load().nextElectionSettings.nextEpochSeatCount; - } - - function getMinimumActiveMembers() external view override returns (uint8) { - return Council.load().nextElectionSettings.minimumActiveMembers; - } - - function getEpochIndex() external view override returns (uint) { - return Council.load().lastElectionId; - } - - function getEpochStartDate() external view override returns (uint64) { - return Council.load().getCurrentElection().epoch.startDate; - } - - function getEpochEndDate() external view override returns (uint64) { - return Council.load().getCurrentElection().epoch.endDate; - } - - function getNominationPeriodStartDate() external view override returns (uint64) { - return Council.load().getCurrentElection().epoch.nominationPeriodStartDate; - } - - function getVotingPeriodStartDate() external view override returns (uint64) { - return Council.load().getCurrentElection().epoch.votingPeriodStartDate; - } - - function getCurrentPeriod() external view override returns (uint) { - // solhint-disable-next-line numcast/safe-cast - return uint(Council.load().getCurrentPeriod()); - } - - function isNominated(address candidate) external view override returns (bool) { - return Council.load().getCurrentElection().nominees.contains(candidate); - } - - function getNominees() external view override returns (address[] memory) { - return Council.load().getCurrentElection().nominees.values(); - } - - function calculateBallotId( - address[] calldata candidates - ) external pure override returns (bytes32) { - return keccak256(abi.encode(candidates)); - } - - function getBallotVoted(address user) public view override returns (bytes32) { - return Council.load().getCurrentElection().ballotIdsByAddress[user]; - } - - function hasVoted(address user) public view override returns (bool) { - return Council.load().getCurrentElection().ballotIdsByAddress[user] != bytes32(0); - } - - function getVotePower(address user) external view override returns (uint) { - return _getVotePower(user); - } - - function getBallotVotes(bytes32 ballotId) external view override returns (uint) { - return Council.load().getCurrentElection().ballotsById[ballotId].votes; - } - - function getBallotCandidates( - bytes32 ballotId - ) external view override returns (address[] memory) { - return Council.load().getCurrentElection().ballotsById[ballotId].candidates; - } - - function isElectionEvaluated() public view override returns (bool) { - return Council.load().getCurrentElection().evaluated; - } - - function getCandidateVotes(address candidate) external view override returns (uint) { - return Council.load().getCurrentElection().candidateVotes[candidate]; - } - - function getElectionWinners() external view override returns (address[] memory) { - return Council.load().getCurrentElection().winners.values(); - } - - function getCouncilToken() public view override returns (address) { - return Council.load().councilToken; - } - - function getCouncilMembers() external view override returns (address[] memory) { - return Council.load().councilMembers.values(); - } -} diff --git a/protocol/governance/contracts/modules/core/CcipReceiverModule.sol b/protocol/governance/contracts/modules/core/CcipReceiverModule.sol new file mode 100644 index 0000000000..f2ed096950 --- /dev/null +++ b/protocol/governance/contracts/modules/core/CcipReceiverModule.sol @@ -0,0 +1,12 @@ +//SPDX-License-Identifier: MIT +pragma solidity >=0.8.11 <0.9.0; + +import {CcipReceiverModule as BaseCcipReceiverModule} from "@synthetixio/core-modules/contracts/modules/CcipReceiverModule.sol"; + +/** + * @title Module that handles receiving ccip messages. + */ +// solhint-disable-next-line no-empty-blocks +contract CcipReceiverModule is BaseCcipReceiverModule { + +} diff --git a/protocol/governance/contracts/modules/core/CrossChainModule.sol b/protocol/governance/contracts/modules/core/CrossChainModule.sol new file mode 100644 index 0000000000..df36c8c708 --- /dev/null +++ b/protocol/governance/contracts/modules/core/CrossChainModule.sol @@ -0,0 +1,12 @@ +//SPDX-License-Identifier: MIT +pragma solidity >=0.8.11 <0.9.0; + +import {CrossChainModule as BaseCrossChainModule} from "@synthetixio/core-modules/contracts/modules/CrossChainModule.sol"; + +/** + * @title Module that handles anything related to cross-chain. + */ +// solhint-disable-next-line no-empty-blocks +contract CrossChainModule is BaseCrossChainModule { + +} diff --git a/protocol/governance/contracts/modules/core/ElectionInspectorModule.sol b/protocol/governance/contracts/modules/core/ElectionInspectorModule.sol index 0e13be0ec7..82f0927625 100644 --- a/protocol/governance/contracts/modules/core/ElectionInspectorModule.sol +++ b/protocol/governance/contracts/modules/core/ElectionInspectorModule.sol @@ -1,77 +1,67 @@ //SPDX-License-Identifier: MIT pragma solidity ^0.8.0; -import "../../interfaces/IElectionInspectorModule.sol"; -import "../../submodules/election/ElectionBase.sol"; +import {SetUtil} from "@synthetixio/core-contracts/contracts/utils/SetUtil.sol"; +import {IElectionInspectorModule} from "../../interfaces/IElectionInspectorModule.sol"; +import {Ballot} from "../../storage/Ballot.sol"; +import {Election} from "../../storage/Election.sol"; +import {Epoch} from "../../storage/Epoch.sol"; -contract ElectionInspectorModule is IElectionInspectorModule, ElectionBase { +contract ElectionInspectorModule is IElectionInspectorModule { using SetUtil for SetUtil.AddressSet; + using Ballot for Ballot.Data; + using Epoch for Epoch.Data; - function getEpochStartDateForIndex(uint epochIndex) external view override returns (uint64) { - return Election.load(epochIndex).epoch.startDate; + function getEpochStartDateForIndex(uint256 epochIndex) external view override returns (uint64) { + return Epoch.load(epochIndex).startDate; } - function getEpochEndDateForIndex(uint epochIndex) external view override returns (uint64) { - return Election.load(epochIndex).epoch.endDate; + function getEpochEndDateForIndex(uint256 epochIndex) external view override returns (uint64) { + return Epoch.load(epochIndex).endDate; } function getNominationPeriodStartDateForIndex( - uint epochIndex + uint256 epochIndex ) external view override returns (uint64) { - return Election.load(epochIndex).epoch.nominationPeriodStartDate; + return Epoch.load(epochIndex).nominationPeriodStartDate; } function getVotingPeriodStartDateForIndex( - uint epochIndex + uint256 epochIndex ) external view override returns (uint64) { - return Election.load(epochIndex).epoch.votingPeriodStartDate; + return Epoch.load(epochIndex).votingPeriodStartDate; } function wasNominated( address candidate, - uint epochIndex + uint256 epochIndex ) external view override returns (bool) { return Election.load(epochIndex).nominees.contains(candidate); } - function getNomineesAtEpoch(uint epochIndex) external view override returns (address[] memory) { + function getNomineesAtEpoch( + uint256 epochIndex + ) external view override returns (address[] memory) { return Election.load(epochIndex).nominees.values(); } - function getBallotVotedAtEpoch( + function hasVotedInEpoch( address user, - uint epochIndex - ) public view override returns (bytes32) { - return Election.load(epochIndex).ballotIdsByAddress[user]; - } - - function hasVotedInEpoch(address user, uint epochIndex) external view override returns (bool) { - return getBallotVotedAtEpoch(user, epochIndex) != bytes32(0); - } - - function getBallotVotesInEpoch( - bytes32 ballotId, - uint epochIndex - ) external view override returns (uint) { - return Election.load(epochIndex).ballotsById[ballotId].votes; - } - - function getBallotCandidatesInEpoch( - bytes32 ballotId, - uint epochIndex - ) external view override returns (address[] memory) { - return Election.load(epochIndex).ballotsById[ballotId].candidates; + uint256 chainId, + uint256 epochIndex + ) external view override returns (bool) { + return Ballot.load(epochIndex, user, chainId).hasVoted(); } function getCandidateVotesInEpoch( address candidate, - uint epochIndex + uint256 epochIndex ) external view override returns (uint) { - return Election.load(epochIndex).candidateVotes[candidate]; + return Election.load(epochIndex).candidateVoteTotals[candidate]; } function getElectionWinnersInEpoch( - uint epochIndex + uint256 epochIndex ) external view override returns (address[] memory) { return Election.load(epochIndex).winners.values(); } diff --git a/protocol/governance/contracts/modules/core/ElectionModule.sol b/protocol/governance/contracts/modules/core/ElectionModule.sol index 3682d8c81e..26b440813e 100644 --- a/protocol/governance/contracts/modules/core/ElectionModule.sol +++ b/protocol/governance/contracts/modules/core/ElectionModule.sol @@ -1,184 +1,588 @@ //SPDX-License-Identifier: MIT pragma solidity ^0.8.0; -import "@synthetixio/core-contracts/contracts/utils/ERC2771Context.sol"; -import "@synthetixio/core-contracts/contracts/utils/DecimalMath.sol"; -import "../../interfaces/IElectionModule.sol"; -import "../../interfaces/ISynthetixElectionModule.sol"; -import "../../submodules/election/DebtShareManager.sol"; -import "../../submodules/election/CrossChainDebtShareManager.sol"; - -import "./BaseElectionModule.sol"; - -/// @title Module for electing a council, represented by a set of NFT holders -/// @notice This extends the base ElectionModule by determining voting power by Synthetix v2 debt share -contract ElectionModule is - ISynthetixElectionModule, - DebtShareManager, - CrossChainDebtShareManager, - BaseElectionModule -{ - error TooManyCandidates(); - error WrongInitializer(); - - /// @dev The BaseElectionModule initializer should not be called, and this one must be called instead - function initOrUpgradeElectionModule( - address[] memory, - uint8, - uint64, - uint64, - uint64 - ) external override(BaseElectionModule, IElectionModule) { +import {ERC2771Context} from "@synthetixio/core-contracts/contracts/utils/ERC2771Context.sol"; +import {ParameterError} from "@synthetixio/core-contracts/contracts/errors/ParameterError.sol"; +import {SafeCastU256} from "@synthetixio/core-contracts/contracts/utils/SafeCast.sol"; +import {SetUtil} from "@synthetixio/core-contracts/contracts/utils/SetUtil.sol"; +import {OwnableStorage} from "@synthetixio/core-contracts/contracts/ownership/OwnableStorage.sol"; +import {CrossChain} from "@synthetixio/core-modules/contracts/storage/CrossChain.sol"; +import {IElectionModule} from "../../interfaces/IElectionModule.sol"; +import {ElectionTally} from "../../submodules/election/ElectionTally.sol"; +import {Ballot} from "../../storage/Ballot.sol"; +import {Council} from "../../storage/Council.sol"; +import {CouncilMembers} from "../../storage/CouncilMembers.sol"; +import {Election} from "../../storage/Election.sol"; +import {Epoch} from "../../storage/Epoch.sol"; +import {ElectionSettings} from "../../storage/ElectionSettings.sol"; +import {ElectionModuleSatellite} from "./ElectionModuleSatellite.sol"; + +contract ElectionModule is IElectionModule, ElectionModuleSatellite, ElectionTally { + using SetUtil for SetUtil.AddressSet; + using SetUtil for SetUtil.Bytes32Set; + using Council for Council.Data; + using ElectionSettings for ElectionSettings.Data; + using CouncilMembers for CouncilMembers.Data; + using CrossChain for CrossChain.Data; + using SafeCastU256 for uint256; + using Ballot for Ballot.Data; + using Epoch for Epoch.Data; + + uint256 private constant _CROSSCHAIN_GAS_LIMIT = 100000; + uint8 private constant _MAX_BALLOT_SIZE = 1; + + /** + * @dev Utility method for initializing a new Satellite chain + */ + function initElectionModuleSatellite(uint256 chainId) external payable { OwnableStorage.onlyOwner(); - revert WrongInitializer(); + + CrossChain.Data storage cc = CrossChain.load(); + + cc.validateChainId(chainId); + + CouncilMembers.Data storage councilMembers = CouncilMembers.load(); + Council.Data storage council = Council.load(); + Epoch.Data memory epoch = council.getCurrentEpoch(); + + cc.transmit( + chainId.to64(), + abi.encodeWithSelector( + this._recvInitElectionModuleSatellite.selector, + council.currentElectionId, + epoch.startDate, + epoch.nominationPeriodStartDate, + epoch.votingPeriodStartDate, + epoch.endDate, + councilMembers.councilMembers.values() + ), + _CROSSCHAIN_GAS_LIMIT + ); } - /// @dev Overloads the BaseElectionModule initializer with an additional parameter for the debt share contract - function initOrUpgradeElectionModule( - address[] memory firstCouncil, + function initOrUpdateElectionSettings( + address[] memory initialCouncil, uint8 minimumActiveMembers, - uint64 nominationPeriodStartDate, - uint64 votingPeriodStartDate, - uint64 epochEndDate, - address debtShareContract + uint64 initialNominationPeriodStartDate, // timestamp + uint64 administrationPeriodDuration, // days + uint64 nominationPeriodDuration, // days + uint64 votingPeriodDuration // days ) external override { OwnableStorage.onlyOwner(); - if (Council.load().initialized) { - return; + + if (initialCouncil.length > type(uint8).max) { + revert TooManyMembers(); } - _setDebtShareContract(debtShareContract); - _initOrUpgradeElectionModule( - firstCouncil, + Council.Data storage council = Council.load(); + + // Convert given days to seconds + administrationPeriodDuration = administrationPeriodDuration * 1 days; + nominationPeriodDuration = nominationPeriodDuration * 1 days; + votingPeriodDuration = votingPeriodDuration * 1 days; + + // solhint-disable-next-line numcast/safe-cast + uint8 epochSeatCount = uint8(initialCouncil.length); + + uint64 epochDuration = administrationPeriodDuration + + nominationPeriodDuration + + votingPeriodDuration; + + ElectionSettings.Data storage nextElectionSettings = council.getNextElectionSettings(); + + // Set the expected epoch durations for next council + nextElectionSettings.setElectionSettings( + epochSeatCount, minimumActiveMembers, + epochDuration, + nominationPeriodDuration, + votingPeriodDuration, + 3 days // maxDateAdjustmentTolerance + ); + + // Initialize first epoch if necessary + if (!_isInitialized()) { + _initElectionSettings( + council, + nextElectionSettings, + initialCouncil, + initialNominationPeriodStartDate + ); + } + } + + function _initElectionSettings( + Council.Data storage council, + ElectionSettings.Data storage electionSettings, + address[] memory initialCouncil, + uint64 nominationPeriodStartDate // timestamp + ) internal { + ElectionSettings.Data storage currentSettings = council.getCurrentElectionSettings(); + currentSettings.copyMissingFrom(electionSettings); + + // calculate periods timestamps based on durations + uint64 epochStartDate = block.timestamp.to64(); + uint64 epochEndDate = epochStartDate + electionSettings.epochDuration; + uint64 votingPeriodStartDate = epochEndDate - electionSettings.votingPeriodDuration; + + // Allow to not set "nominationPeriodStartDate" and infer it from the durations + if (nominationPeriodStartDate == 0) { + nominationPeriodStartDate = + votingPeriodStartDate - + electionSettings.nominationPeriodDuration; + } + + Epoch.Data storage firstEpoch = council.getCurrentEpoch(); + council.configureEpochSchedule( + firstEpoch, + epochStartDate, nominationPeriodStartDate, votingPeriodStartDate, epochEndDate ); + + _addCouncilMembers(initialCouncil, 0); + + council.initialized = true; + + emit ElectionModuleInitialized(); + emit EpochStarted(0); } - /// @dev Overrides the BaseElectionModule nominate function to only allow 1 candidate to be nominated - function cast( - address[] calldata candidates - ) - public - override(BaseElectionModule, IElectionModule) - onlyInPeriod(Council.ElectionPeriod.Vote) - { - if (candidates.length > 1) { - revert TooManyCandidates(); - } + function tweakEpochSchedule( + uint64 newNominationPeriodStartDate, + uint64 newVotingPeriodStartDate, + uint64 newEpochEndDate + ) external override { + OwnableStorage.onlyOwner(); + Council.onlyInPeriod(Epoch.ElectionPeriod.Administration); + Council.Data storage council = Council.load(); + + Epoch.Data storage currentEpoch = council.getCurrentEpoch(); + Epoch.Data memory newEpoch = Epoch.Data( + currentEpoch.startDate, + newNominationPeriodStartDate, + newVotingPeriodStartDate, + newEpochEndDate + ); + + council.validateEpochScheduleTweak(currentEpoch, newEpoch); + + CrossChain.Data storage cc = CrossChain.load(); + cc.broadcast( + cc.getSupportedNetworks(), + abi.encodeWithSelector( + this._recvTweakEpochSchedule.selector, + council.currentElectionId, + newEpoch.nominationPeriodStartDate, + newEpoch.votingPeriodStartDate, + newEpoch.endDate + ), + _CROSSCHAIN_GAS_LIMIT + ); - super.cast(candidates); + emit EpochScheduleUpdated( + newEpoch.nominationPeriodStartDate, + newEpoch.votingPeriodStartDate, + newEpoch.endDate + ); } - // --------------------------------------- - // Debt shares - // --------------------------------------- + function setNextElectionSettings( + uint8 epochSeatCount, + uint8 minimumActiveMembers, + uint64 epochDuration, // days + uint64 nominationPeriodDuration, // days + uint64 votingPeriodDuration, // days + uint64 maxDateAdjustmentTolerance // days + ) external override { + OwnableStorage.onlyOwner(); + Council.onlyInPeriod(Epoch.ElectionPeriod.Administration); - function setDebtShareContract( - address debtShareContract - ) external override onlyInPeriod(Council.ElectionPeriod.Administration) { + Council.load().getNextElectionSettings().setElectionSettings( + epochSeatCount, + minimumActiveMembers, + epochDuration * 1 days, + nominationPeriodDuration * 1 days, + votingPeriodDuration * 1 days, + maxDateAdjustmentTolerance * 1 days + ); + } + + function dismissMembers(address[] calldata membersToDismiss) external payable override { OwnableStorage.onlyOwner(); - _setDebtShareContract(debtShareContract); + Council.Data storage council = Council.load(); + Epoch.Data storage epoch = council.getCurrentEpoch(); + + CrossChain.Data storage cc = CrossChain.load(); + cc.broadcast( + cc.getSupportedNetworks(), + abi.encodeWithSelector( + this._recvDismissMembers.selector, + membersToDismiss, + council.currentElectionId + ), + _CROSSCHAIN_GAS_LIMIT + ); + + CouncilMembers.Data storage membersStore = CouncilMembers.load(); + if (epoch.getCurrentPeriod() != Epoch.ElectionPeriod.Administration) return; + + // Don't immediately jump to an election if the council still has enough members + if ( + membersStore.councilMembers.length() >= + council.getCurrentElectionSettings().minimumActiveMembers + ) { + return; + } + + council.jumpToNominationPeriod(); - emit DebtShareContractSet(debtShareContract); + emit EmergencyElectionStarted(council.currentElectionId); } - function getDebtShareContract() external view override returns (address) { - return address(DebtShare.load().debtShareContract); + function nominate() public override { + Council.onlyInPeriods(Epoch.ElectionPeriod.Nomination, Epoch.ElectionPeriod.Vote); + + SetUtil.AddressSet storage nominees = Council.load().getCurrentElection().nominees; + address sender = ERC2771Context._msgSender(); + + if (nominees.contains(sender)) revert AlreadyNominated(); + + nominees.add(sender); + + emit CandidateNominated(sender, Council.load().currentElectionId); } - function setDebtShareSnapshotId( - uint snapshotId - ) external override onlyInPeriod(Council.ElectionPeriod.Nomination) { - OwnableStorage.onlyOwner(); - _setDebtShareSnapshotId(snapshotId); + function withdrawNomination() external override { + SetUtil.AddressSet storage nominees = Council.load().getCurrentElection().nominees; + Council.onlyInPeriod(Epoch.ElectionPeriod.Nomination); + + address sender = ERC2771Context._msgSender(); + + if (!nominees.contains(sender)) revert NotNominated(); + + nominees.remove(sender); + + emit NominationWithdrawn(sender, Council.load().currentElectionId); } - function getDebtShareSnapshotId() external view override returns (uint) { - return _getDebtShareSnapshotId(); + function _recvCast( + uint256 epochIndex, + address voter, + uint256 votingPower, + uint256 chainId, + address[] calldata candidates, + uint256[] calldata amounts + ) external override { + CrossChain.onlyCrossChain(); + Council.onlyInPeriod(Epoch.ElectionPeriod.Vote); + + if (candidates.length > _MAX_BALLOT_SIZE) { + revert ParameterError.InvalidParameter("candidates", "too many candidates"); + } + + if (candidates.length != amounts.length) { + revert ParameterError.InvalidParameter("candidates", "length must match amounts"); + } + + Council.Data storage council = Council.load(); + Election.Data storage election = council.getCurrentElection(); + uint256 currentElectionId = council.currentElectionId; + + if (epochIndex != currentElectionId) { + revert ParameterError.InvalidParameter("epochIndex", "invalid epoch index"); + } + + _validateCandidates(candidates); + + Ballot.Data storage ballot = Ballot.load(council.currentElectionId, voter, chainId); + + ballot.votedCandidates = candidates; + ballot.amounts = amounts; + ballot.votingPower = votingPower; + + ballot.validate(); + + bytes32 ballotPtr; + assembly { + ballotPtr := ballot.slot + } + + if (!election.ballotPtrs.contains(ballotPtr)) { + election.ballotPtrs.add(ballotPtr); + } + + emit VoteRecorded(voter, chainId, currentElectionId, ballot.votingPower, candidates); } - function getDebtShare(address user) external view override returns (uint) { - return _getDebtShare(user); + function _recvWithdrawVote( + uint256 epochIndex, + address voter, + uint256 chainId, + address[] calldata candidates + ) external override { + CrossChain.onlyCrossChain(); + Council.onlyInPeriod(Epoch.ElectionPeriod.Vote); + + if (candidates.length > _MAX_BALLOT_SIZE) { + revert ParameterError.InvalidParameter("candidates", "too many candidates"); + } + + Council.Data storage council = Council.load(); + Election.Data storage election = council.getCurrentElection(); + uint256 currentElectionId = council.currentElectionId; + + if (epochIndex != currentElectionId) { + revert ParameterError.InvalidParameter("epochIndex", "invalid epoch index"); + } + + _validateCandidates(candidates); + + Ballot.Data storage ballot = Ballot.load(council.currentElectionId, voter, chainId); + + ballot.amounts = new uint256[](0); + ballot.votedCandidates = new address[](0); + + ballot.validate(); + + bytes32 ballotPtr; + assembly { + ballotPtr := ballot.slot + } + + if (!election.ballotPtrs.contains(ballotPtr)) { + election.ballotPtrs.add(ballotPtr); + } + + emit VoteWithdrawn(voter, chainId, currentElectionId, candidates); } - // --------------------------------------- - // Cross chain debt shares - // --------------------------------------- + /// @dev ElectionTally needs to be extended to specify how votes are counted + function evaluate(uint256 numBallots) external override { + Council.onlyInPeriod(Epoch.ElectionPeriod.Evaluation); - function setCrossChainDebtShareMerkleRoot( - bytes32 merkleRoot, - uint blocknumber - ) external override onlyInPeriod(Council.ElectionPeriod.Nomination) { - OwnableStorage.onlyOwner(); - _setCrossChainDebtShareMerkleRoot(merkleRoot, blocknumber); + Council.Data storage council = Council.load(); + Election.Data storage election = council.getCurrentElection(); + Epoch.Data memory epoch = council.getCurrentEpoch(); + ElectionSettings.Data storage electionSettings = ElectionSettings.load( + council.currentElectionId + ); + if (election.nominees.values().length < electionSettings.minimumActiveMembers) { + CrossChain.Data storage cc = CrossChain.load(); + cc.broadcast( + cc.getSupportedNetworks(), + abi.encodeWithSelector( + this._recvTweakEpochSchedule.selector, + council.currentElectionId, + epoch.nominationPeriodStartDate, + epoch.votingPeriodStartDate, + epoch.endDate + electionSettings.votingPeriodDuration + ), + _CROSSCHAIN_GAS_LIMIT + ); + } else { + if (election.evaluated) revert ElectionAlreadyEvaluated(); + + _evaluateNextBallotBatch(numBallots); + + uint256 currentEpochIndex = council.currentElectionId; + + uint256 totalBallots = election.ballotPtrs.length(); + if (election.numEvaluatedBallots < totalBallots) { + emit ElectionBatchEvaluated( + currentEpochIndex, + election.numEvaluatedBallots, + totalBallots + ); + } else { + election.evaluated = true; + emit ElectionEvaluated(currentEpochIndex, totalBallots); + } + } + } + + /// @dev Burns previous NFTs and mints new ones + function resolve() public payable virtual override { + Council.onlyInPeriod(Epoch.ElectionPeriod.Evaluation); + + Council.Data storage council = Council.load(); + Election.Data storage election = council.getCurrentElection(); + + if (!election.evaluated) revert ElectionNotEvaluated(); + + ElectionSettings.Data storage currentElectionSettings = council + .getCurrentElectionSettings(); + ElectionSettings.Data storage nextElectionSettings = council.getNextElectionSettings(); + + nextElectionSettings.copyMissingFrom(currentElectionSettings); + Epoch.Data memory nextEpoch = _computeEpochFromSettings(nextElectionSettings); + + council.validateEpochSchedule( + nextEpoch.startDate, + nextEpoch.nominationPeriodStartDate, + nextEpoch.votingPeriodStartDate, + nextEpoch.endDate + ); - emit CrossChainDebtShareMerkleRootSet( - merkleRoot, - blocknumber, - Council.load().lastElectionId + council.newElection(); + + CrossChain.Data storage cc = CrossChain.load(); + cc.broadcast( + cc.getSupportedNetworks(), + abi.encodeWithSelector( + this._recvResolve.selector, + council.currentElectionId, + nextEpoch.startDate, + nextEpoch.nominationPeriodStartDate, + nextEpoch.votingPeriodStartDate, + nextEpoch.endDate, + election.winners.values() + ), + _CROSSCHAIN_GAS_LIMIT ); + + election.resolved = true; + + emit EpochStarted(council.currentElectionId); } - function getCrossChainDebtShareMerkleRoot() external view override returns (bytes32) { - return _getCrossChainDebtShareMerkleRoot(); + function _computeEpochFromSettings( + ElectionSettings.Data storage settings + ) private view returns (Epoch.Data memory epoch) { + uint64 startDate = SafeCastU256.to64(block.timestamp); + uint64 endDate = startDate + settings.epochDuration; + uint64 votingPeriodStartDate = endDate - settings.votingPeriodDuration; + uint64 nominationPeriodStartDate = votingPeriodStartDate - + settings.nominationPeriodDuration; + + return + Epoch.Data({ + startDate: startDate, + votingPeriodStartDate: votingPeriodStartDate, + nominationPeriodStartDate: nominationPeriodStartDate, + endDate: endDate + }); } - function getCrossChainDebtShareMerkleRootBlockNumber() external view override returns (uint) { - return _getCrossChainDebtShareMerkleRootBlockNumber(); + function getEpochSchedule() external view override returns (Epoch.Data memory epoch) { + return Council.load().getCurrentEpoch(); } - function declareCrossChainDebtShare( - address user, - uint256 debtShare, - bytes32[] calldata merkleProof - ) public override onlyInPeriod(Council.ElectionPeriod.Vote) { - _declareCrossChainDebtShare(user, debtShare, merkleProof); + function getElectionSettings() + external + view + override + returns (ElectionSettings.Data memory settings) + { + return Council.load().getCurrentElectionSettings(); + } - emit CrossChainDebtShareDeclared(user, debtShare); + function getNextElectionSettings() + external + view + override + returns (ElectionSettings.Data memory settings) + { + return Council.load().getNextElectionSettings(); } - function getDeclaredCrossChainDebtShare(address user) external view override returns (uint) { - return _getDeclaredCrossChainDebtShare(user); + function getEpochIndex() external view override returns (uint256) { + return Council.load().currentElectionId; } - function declareAndCast( - uint256 debtShare, - bytes32[] calldata merkleProof, - address[] calldata candidates - ) public override onlyInPeriod(Council.ElectionPeriod.Vote) { - declareCrossChainDebtShare(ERC2771Context._msgSender(), debtShare, merkleProof); + function getCurrentPeriod() external view override returns (uint256) { + // solhint-disable-next-line numcast/safe-cast + return uint256(Council.load().getCurrentEpoch().getCurrentPeriod()); + } - cast(candidates); + function isNominated(address candidate) external view override returns (bool) { + return Council.load().getCurrentElection().nominees.contains(candidate); } - // --------------------------------------- - // Internal - // --------------------------------------- + function getNominees() external view override returns (address[] memory) { + return Council.load().getCurrentElection().nominees.values(); + } - function _sqrt(uint x) internal pure returns (uint y) { - uint z = (x + 1) / 2; - y = x; - while (z < y) { - y = z; - z = (x / z + z) / 2; - } + function hasVoted(address user, uint256 chainId) public view override returns (bool) { + Council.Data storage council = Council.load(); + Ballot.Data storage ballot = Ballot.load(council.currentElectionId, user, chainId); + return ballot.votingPower > 0 && ballot.votedCandidates.length > 0; + } + + function getVotePower( + address user, + uint256 chainId, + uint256 electionId + ) external view override returns (uint256) { + Ballot.Data storage ballot = Ballot.load(electionId, user, chainId); + return ballot.votingPower; } - /// @dev Overrides the user's voting power by combining local chain debt share with debt shares in other chains, quadratically filtered - function _getVotePower(address user) internal view virtual override returns (uint) { - uint votePower = _getDebtShare(user) + _getDeclaredCrossChainDebtShare(user); + function getBallot( + address voter, + uint256 chainId, + uint256 electionId + ) external pure override returns (Ballot.Data memory) { + return Ballot.load(electionId, voter, chainId); + } - return _sqrt(votePower); + function getBallotCandidates( + address voter, + uint256 chainId, + uint256 electionId + ) external view override returns (address[] memory) { + return Ballot.load(electionId, voter, chainId).votedCandidates; } - function _createNewEpoch() internal virtual { - DebtShare.Data storage store = DebtShare.load(); + function isElectionEvaluated() public view override returns (bool) { + return Council.load().getCurrentElection().evaluated; + } - store.debtShareIds.push(); - store.crossChainDebtShareData.push(); + function getCandidateVotes(address candidate) external view override returns (uint256) { + return Council.load().getCurrentElection().candidateVoteTotals[candidate]; + } + + function getElectionWinners() external view override returns (address[] memory) { + return Council.load().getCurrentElection().winners.values(); + } + + function getCouncilToken() public view override returns (address) { + return CouncilMembers.load().councilToken; + } + + function getCouncilMembers() external view override returns (address[] memory) { + return CouncilMembers.load().councilMembers.values(); + } + + function _validateCandidates(address[] calldata candidates) internal virtual { + uint256 length = candidates.length; + + if (length == 0) { + revert NoCandidates(); + } + + SetUtil.AddressSet storage nominees = Council.load().getCurrentElection().nominees; + + for (uint256 i = 0; i < length; i++) { + address candidate = candidates[i]; + + // Reject candidates that are not nominated. + if (!nominees.contains(candidate)) { + revert NotNominated(); + } + + // Reject duplicate candidates. + if (i < length - 1) { + for (uint256 j = i + 1; j < length; j++) { + address otherCandidate = candidates[j]; + + if (candidate == otherCandidate) { + revert DuplicateCandidates(candidate); + } + } + } + } } } diff --git a/protocol/governance/contracts/modules/core/ElectionModuleSatellite.sol b/protocol/governance/contracts/modules/core/ElectionModuleSatellite.sol new file mode 100644 index 0000000000..8f3d98dc5d --- /dev/null +++ b/protocol/governance/contracts/modules/core/ElectionModuleSatellite.sol @@ -0,0 +1,194 @@ +//SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {SetUtil} from "@synthetixio/core-contracts/contracts/utils/SetUtil.sol"; +import {CrossChain} from "@synthetixio/core-modules/contracts/storage/CrossChain.sol"; +import {OwnableStorage} from "@synthetixio/core-contracts/contracts/ownership/OwnableStorage.sol"; +import {InitializableMixin} from "@synthetixio/core-contracts/contracts/initializable/InitializableMixin.sol"; +import {ERC2771Context} from "@synthetixio/core-contracts/contracts/utils/ERC2771Context.sol"; +import {IElectionModule} from "../../interfaces/IElectionModule.sol"; +import {IElectionModuleSatellite} from "../../interfaces/IElectionModuleSatellite.sol"; +import {ElectionCredentials} from "../../submodules/election/ElectionCredentials.sol"; +import {Ballot} from "../../storage/Ballot.sol"; +import {CouncilMembers} from "../../storage/CouncilMembers.sol"; +import {Council} from "../../storage/Council.sol"; +import {Epoch} from "../../storage/Epoch.sol"; + +contract ElectionModuleSatellite is + IElectionModuleSatellite, + InitializableMixin, + ElectionCredentials +{ + using Ballot for Ballot.Data; + using Council for Council.Data; + using CouncilMembers for CouncilMembers.Data; + using CrossChain for CrossChain.Data; + using Epoch for Epoch.Data; + using SetUtil for SetUtil.AddressSet; + + uint256 private constant _CROSSCHAIN_GAS_LIMIT = 100000; + + function _recvInitElectionModuleSatellite( + uint256 epochIndex, + uint64 epochStartDate, + uint64 nominationPeriodStartDate, + uint64 votingPeriodStartDate, + uint64 epochEndDate, + address[] calldata councilMembers + ) external virtual { + CrossChain.onlyCrossChain(); + + Council.Data storage council = Council.load(); + + if (_isInitialized()) { + return; + } + + council.initialized = true; + + _setupEpoch( + epochIndex, + epochStartDate, + nominationPeriodStartDate, + votingPeriodStartDate, + epochEndDate, + councilMembers + ); + } + + function isElectionModuleInitialized() public view override returns (bool) { + return _isInitialized(); + } + + function _isInitialized() internal view override returns (bool) { + return Council.load().initialized; + } + + function cast( + address[] calldata candidates, + uint256[] calldata amounts + ) public payable override { + Council.onlyInPeriod(Epoch.ElectionPeriod.Vote); + + address sender = ERC2771Context._msgSender(); + + /// @dev: load ballot with total votingPower, should have been prepared before, + /// calling the prepareBallotWithSnapshot method + uint256 currentEpoch = Council.load().currentElectionId; + Ballot.Data storage ballot = Ballot.load(currentEpoch, sender, block.chainid); + + if (ballot.votingPower == 0) { + revert NoVotingPower(sender, currentEpoch); + } + + CrossChain.Data storage cc = CrossChain.load(); + cc.transmit( + cc.getChainIdAt(0), + abi.encodeWithSelector( + IElectionModule._recvCast.selector, + currentEpoch, + sender, + ballot.votingPower, + block.chainid, + candidates, + amounts + ), + _CROSSCHAIN_GAS_LIMIT + ); + } + + function withdrawVote(address[] calldata candidates) public payable override { + Council.onlyInPeriod(Epoch.ElectionPeriod.Vote); + + address sender = ERC2771Context._msgSender(); + + uint256 currentEpoch = Council.load().currentElectionId; + + CrossChain.Data storage cc = CrossChain.load(); + cc.transmit( + cc.getChainIdAt(0), + abi.encodeWithSelector( + IElectionModule._recvWithdrawVote.selector, + currentEpoch, + sender, + block.chainid, + candidates + ), + _CROSSCHAIN_GAS_LIMIT + ); + } + + function _recvDismissMembers( + address[] calldata membersToDismiss, + uint256 epochIndex + ) external override { + CrossChain.onlyCrossChain(); + + _removeCouncilMembers(membersToDismiss, epochIndex); + + emit CouncilMembersDismissed(membersToDismiss, epochIndex); + } + + function _recvResolve( + uint256 epochIndex, + uint64 epochStartDate, + uint64 nominationPeriodStartDate, + uint64 votingPeriodStartDate, + uint64 epochEndDate, + address[] calldata councilMembers + ) external override { + CrossChain.onlyCrossChain(); + + _setupEpoch( + epochIndex, + epochStartDate, + nominationPeriodStartDate, + votingPeriodStartDate, + epochEndDate, + councilMembers + ); + } + + function _recvTweakEpochSchedule( + uint256 epochIndex, + uint64 nominationPeriodStartDate, + uint64 votingPeriodStartDate, + uint64 epochEndDate + ) external override { + CrossChain.onlyCrossChain(); + + Epoch.Data storage epoch = Epoch.load(epochIndex); + + epoch.setEpochDates( + epoch.startDate, + nominationPeriodStartDate, + votingPeriodStartDate, + epochEndDate + ); + } + + function _setupEpoch( + uint256 epochIndex, + uint64 epochStartDate, + uint64 nominationPeriodStartDate, + uint64 votingPeriodStartDate, + uint64 epochEndDate, + address[] calldata councilMembers + ) private { + Council.Data storage council = Council.load(); + uint256 prevEpochIndex = council.currentElectionId; + + council.currentElectionId = epochIndex; + + Epoch.Data storage epoch = Epoch.load(epochIndex); + epoch.setEpochDates( + epochStartDate, + nominationPeriodStartDate, + votingPeriodStartDate, + epochEndDate + ); + + _removeAllCouncilMembers(prevEpochIndex); + _addCouncilMembers(councilMembers, epochIndex); + } +} diff --git a/protocol/governance/contracts/modules/core/InitialModuleBundle.sol b/protocol/governance/contracts/modules/core/InitialModuleBundle.sol index 594a309cc6..19d7bba10e 100644 --- a/protocol/governance/contracts/modules/core/InitialModuleBundle.sol +++ b/protocol/governance/contracts/modules/core/InitialModuleBundle.sol @@ -1,12 +1,8 @@ //SPDX-License-Identifier: MIT pragma solidity >=0.8.11 <0.9.0; -import "@synthetixio/core-modules/contracts/modules/OwnerModule.sol"; -import "@synthetixio/core-modules/contracts/modules/UpgradeModule.sol"; - -// The below contract is only used during initialization as a kernel for the first release which the system can be upgraded onto. -// Subsequent upgrades will not need this module bundle -// In the future on live networks, we may want to find some way to hardcode the owner address here to prevent grieving +import {OwnerModule} from "@synthetixio/core-modules/contracts/modules/OwnerModule.sol"; +import {UpgradeModule} from "@synthetixio/core-modules/contracts/modules/UpgradeModule.sol"; // solhint-disable-next-line no-empty-blocks contract InitialModuleBundle is OwnerModule, UpgradeModule { diff --git a/protocol/governance/contracts/modules/core/SnapshotVotePowerModule.sol b/protocol/governance/contracts/modules/core/SnapshotVotePowerModule.sol new file mode 100644 index 0000000000..8a86b88736 --- /dev/null +++ b/protocol/governance/contracts/modules/core/SnapshotVotePowerModule.sol @@ -0,0 +1,91 @@ +//SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {OwnableStorage} from "@synthetixio/core-contracts/contracts/ownership/OwnableStorage.sol"; +import {SafeCastU256} from "@synthetixio/core-contracts/contracts/utils/SafeCast.sol"; +import {ISnapshotVotePowerModule} from "../../interfaces/ISnapshotVotePowerModule.sol"; +import {ISnapshotRecord} from "../../interfaces/external/ISnapshotRecord.sol"; +import {Council} from "../../storage/Council.sol"; +import {Epoch} from "../../storage/Epoch.sol"; +import {Ballot} from "../../storage/Ballot.sol"; +import {SnapshotVotePower} from "../../storage/SnapshotVotePower.sol"; +import {SnapshotVotePowerEpoch} from "../../storage/SnapshotVotePowerEpoch.sol"; + +contract SnapshotVotePowerModule is ISnapshotVotePowerModule { + using SafeCastU256 for uint256; + + function setSnapshotContract(address snapshotContract, bool enabled) external override { + OwnableStorage.onlyOwner(); + Council.onlyInPeriod(Epoch.ElectionPeriod.Administration); + + SnapshotVotePower.load(snapshotContract).enabled = enabled; + } + + function takeVotePowerSnapshot( + address snapshotContract + ) external override returns (uint128 snapshotId) { + Council.onlyInPeriods(Epoch.ElectionPeriod.Nomination, Epoch.ElectionPeriod.Vote); + + SnapshotVotePower.Data storage snapshotVotePower = SnapshotVotePower.load(snapshotContract); + uint128 electionId = Council.load().currentElectionId.to128(); + + if (!snapshotVotePower.enabled) { + revert InvalidSnapshotContract(); + } + + SnapshotVotePowerEpoch.Data storage snapshotVotePowerEpoch = snapshotVotePower.epochs[ + electionId + ]; + + if (snapshotVotePowerEpoch.snapshotId > 0) { + revert SnapshotAlreadyTaken(snapshotVotePowerEpoch.snapshotId); + } + + snapshotId = block.timestamp.to128(); + ISnapshotRecord(snapshotContract).takeSnapshot(snapshotId); + + snapshotVotePowerEpoch.snapshotId = snapshotId; + } + + function getVotePowerSnapshotId( + address snapshotContract, + uint128 electionId + ) external view returns (uint128) { + return SnapshotVotePower.load(snapshotContract).epochs[electionId].snapshotId; + } + + function prepareBallotWithSnapshot( + address snapshotContract, + address voter + ) external override returns (uint256 power) { + Council.onlyInPeriod(Epoch.ElectionPeriod.Vote); + + uint128 currentEpoch = Council.load().currentElectionId.to128(); + SnapshotVotePower.Data storage snapshotVotePower = SnapshotVotePower.load(snapshotContract); + + if (!snapshotVotePower.enabled) { + revert InvalidSnapshotContract(); + } + + if (snapshotVotePower.epochs[currentEpoch].snapshotId == 0) { + revert SnapshotNotTaken(snapshotContract, currentEpoch); + } + + power = ISnapshotRecord(snapshotContract).balanceOfOnPeriod( + voter, + snapshotVotePower.epochs[currentEpoch].snapshotId + ); + + if (power == 0) { + revert NoPower(snapshotVotePower.epochs[currentEpoch].snapshotId, voter); + } + + if (snapshotVotePower.epochs[currentEpoch].recordedVotingPower[voter] > 0) { + revert BallotAlreadyPrepared(voter, currentEpoch); + } + + Ballot.Data storage ballot = Ballot.load(currentEpoch, voter, block.chainid); + ballot.votingPower += power; + snapshotVotePower.epochs[currentEpoch].recordedVotingPower[voter] = power; + } +} diff --git a/protocol/governance/contracts/modules/council-nft/CouncilTokenModule.sol b/protocol/governance/contracts/modules/council-nft/CouncilTokenModule.sol index ed44af74ab..19c31896fa 100644 --- a/protocol/governance/contracts/modules/council-nft/CouncilTokenModule.sol +++ b/protocol/governance/contracts/modules/council-nft/CouncilTokenModule.sol @@ -1,16 +1,20 @@ //SPDX-License-Identifier: MIT pragma solidity >=0.8.11 <0.9.0; -import "@synthetixio/core-modules/contracts/modules/NftModule.sol"; -import "../../interfaces/ICouncilTokenModule.sol"; - -import "@synthetixio/core-contracts/contracts/utils/SafeCast.sol"; +import {NftModule} from "@synthetixio/core-modules/contracts/modules/NftModule.sol"; +import {ICouncilTokenModule} from "../../interfaces/ICouncilTokenModule.sol"; +/* solhint-disable no-empty-blocks */ /** * @title Module with custom NFT logic for the account token. * @dev See IAccountTokenModule. */ // solhint-disable-next-line no-empty-blocks contract CouncilTokenModule is ICouncilTokenModule, NftModule { + error NotImplemented(); + function _transfer(address, address, uint256) internal pure override { + revert NotImplemented(); + } } +/* solhint-enable no-empty-blocks */ diff --git a/protocol/governance/contracts/storage/Ballot.sol b/protocol/governance/contracts/storage/Ballot.sol index 69897fef40..00629f5ebb 100644 --- a/protocol/governance/contracts/storage/Ballot.sol +++ b/protocol/governance/contracts/storage/Ballot.sol @@ -1,19 +1,61 @@ //SPDX-License-Identifier: MIT pragma solidity ^0.8.0; -import "@synthetixio/core-contracts/contracts/utils/SetUtil.sol"; - +/** + * @title Ballot + * @dev A single vote cast by a address/chainId combination. + * + * A ballot goes through a few stages: + * 1. The ballot is empty and all values are 0 + * 2. The user who wants to vote proves their voting power in an external function, and increases the `votingPower` field as a result + * 3. Once the user has proven their voting power, they can allocate their power to a set of candidates. + */ library Ballot { + error InvalidBallot(); + struct Data { - // Total accumulated votes in this ballot (needs evaluation) - uint votes; - // List of candidates in this ballot - address[] candidates; - // Vote power added per voter - mapping(address => uint) votesByUser; + uint256 votingPower; + address[] votedCandidates; + uint256[] amounts; + } + + function load( + uint256 electionId, + address voter, + uint256 chainId + ) internal pure returns (Data storage self) { + bytes32 s = keccak256( + abi.encode("io.synthetix.governance.Ballot", electionId, voter, chainId) + ); + + assembly { + self.slot := s + } + } + + function hasVoted(Data storage self) internal view returns (bool) { + return self.votedCandidates.length > 0; + } + + function isValid(Data storage self) internal view returns (bool) { + if (self.votedCandidates.length != self.amounts.length) { + return false; + } + + uint256 totalAmount = 0; + for (uint256 i = 0; i < self.votedCandidates.length; i++) { + if (self.amounts[i] == 0) { + return false; + } + totalAmount += self.amounts[i]; + } + + return totalAmount == 0 || totalAmount == self.votingPower; } - function isInitiated(Data storage self) internal view returns (bool) { - return self.votes > 0; + function validate(Data storage self) internal view { + if (!isValid(self)) { + revert InvalidBallot(); + } } } diff --git a/protocol/governance/contracts/storage/Council.sol b/protocol/governance/contracts/storage/Council.sol index 83ac674e7b..be033db8f6 100644 --- a/protocol/governance/contracts/storage/Council.sol +++ b/protocol/governance/contracts/storage/Council.sol @@ -1,78 +1,224 @@ //SPDX-License-Identifier: MIT pragma solidity ^0.8.0; -import "@synthetixio/core-contracts/contracts/utils/SetUtil.sol"; -import "./Election.sol"; +import {SetUtil} from "@synthetixio/core-contracts/contracts/utils/SetUtil.sol"; +import {SafeCastU256} from "@synthetixio/core-contracts/contracts/utils/SafeCast.sol"; +import {Epoch} from "./Epoch.sol"; +import {Election} from "./Election.sol"; +import {ElectionSettings} from "./ElectionSettings.sol"; library Council { + using Epoch for Epoch.Data; + using ElectionSettings for ElectionSettings.Data; + + error NotCallableInCurrentPeriod(); + error InvalidEpochConfiguration(uint256 code, uint64 v1, uint64 v2); + error ChangesCurrentPeriod(); + + bytes32 private constant _SLOT_COUNCIL_STORAGE = + keccak256(abi.encode("io.synthetix.governance.Council")); + struct Data { // True if initializeElectionModule was called bool initialized; - // The address of the council NFT - address councilToken; - // Council member addresses - SetUtil.AddressSet councilMembers; - // Council token id's by council member address - mapping(address => uint) councilTokenIds; - // id of the last election - uint lastElectionId; - ElectionSettings.Data nextElectionSettings; - } - - enum ElectionPeriod { - // Council elected and active - Administration, - // Accepting nominations for next election - Nomination, - // Accepting votes for ongoing election - Vote, - // Votes being counted - Evaluation + // id of the current epoch + uint256 currentElectionId; } function load() internal pure returns (Data storage store) { + bytes32 s = _SLOT_COUNCIL_STORAGE; assembly { - // bytes32(uint(keccak256("io.synthetix.election")) - 1) - store.slot := 0x4a7bae7406c7467d50a80c6842d6ba8287c729469098e48fc594351749ba4b22 + store.slot := s } } - function newElection(Data storage self) internal returns (uint) { - return ++self.lastElectionId; + function newElection(Data storage self) internal returns (uint newElectionId) { + newElectionId = ++self.currentElectionId; + } + + function getCurrentEpoch(Data storage self) internal view returns (Epoch.Data storage epoch) { + return Epoch.load(self.currentElectionId); } function getCurrentElection( Data storage self ) internal view returns (Election.Data storage election) { - return Election.load(self.lastElectionId); + return Election.load(self.currentElectionId); } function getPreviousElection( Data storage self ) internal view returns (Election.Data storage election) { // NOTE: will revert if there was no previous election - return Election.load(self.lastElectionId - 1); + return Election.load(self.currentElectionId - 1); } - /// @dev Determines the current period type according to the current time and the epoch's dates - function getCurrentPeriod(Data storage self) internal view returns (ElectionPeriod) { - Epoch.Data storage epoch = getCurrentElection(self).epoch; + function getCurrentElectionSettings( + Data storage self + ) internal view returns (ElectionSettings.Data storage settings) { + return ElectionSettings.load(self.currentElectionId); + } - // solhint-disable-next-line numcast/safe-cast - uint64 currentTime = uint64(block.timestamp); + function getPreviousElectionSettings( + Data storage self + ) internal view returns (ElectionSettings.Data storage settings) { + // NOTE: will revert if there was no previous settings + return ElectionSettings.load(self.currentElectionId - 1); + } - if (currentTime >= epoch.endDate) { - return ElectionPeriod.Evaluation; + function getNextElectionSettings( + Data storage self + ) internal view returns (ElectionSettings.Data storage settings) { + return ElectionSettings.load(self.currentElectionId + 1); + } + + /// @dev Used to allow certain functions to only operate within a given period + function onlyInPeriod(Epoch.ElectionPeriod period) internal view { + Epoch.ElectionPeriod currentPeriod = getCurrentEpoch(load()).getCurrentPeriod(); + if (currentPeriod != period) { + revert NotCallableInCurrentPeriod(); } + } - if (currentTime >= epoch.votingPeriodStartDate) { - return ElectionPeriod.Vote; + /// @dev Used to allow certain functions to only operate within a given periods + function onlyInPeriods( + Epoch.ElectionPeriod period1, + Epoch.ElectionPeriod period2 + ) internal view { + Epoch.ElectionPeriod currentPeriod = getCurrentEpoch(load()).getCurrentPeriod(); + if (currentPeriod != period1 && currentPeriod != period2) { + revert NotCallableInCurrentPeriod(); } + } - if (currentTime >= epoch.nominationPeriodStartDate) { - return ElectionPeriod.Nomination; + /// @dev Ensures epoch dates are in the correct order, durations are above minimums, etc + function validateEpochSchedule( + Data storage self, + uint64 epochStartDate, + uint64 nominationPeriodStartDate, + uint64 votingPeriodStartDate, + uint64 epochEndDate + ) internal view { + if (epochEndDate <= votingPeriodStartDate) { + revert InvalidEpochConfiguration(1, epochEndDate, votingPeriodStartDate); + } else if (votingPeriodStartDate <= nominationPeriodStartDate) { + revert InvalidEpochConfiguration(2, votingPeriodStartDate, nominationPeriodStartDate); + } else if (nominationPeriodStartDate <= epochStartDate) { + revert InvalidEpochConfiguration(3, nominationPeriodStartDate, epochStartDate); } - return ElectionPeriod.Administration; + uint64 epochDuration = epochEndDate - epochStartDate; + uint64 votingPeriodDuration = epochEndDate - votingPeriodStartDate; + uint64 nominationPeriodDuration = votingPeriodStartDate - nominationPeriodStartDate; + + ElectionSettings.Data storage settings = getCurrentElectionSettings(self); + + if (epochDuration < settings.nominationPeriodDuration + settings.votingPeriodDuration) { + revert InvalidEpochConfiguration( + 4, + epochDuration, + settings.nominationPeriodDuration + settings.votingPeriodDuration + ); + } else if (nominationPeriodDuration < settings.nominationPeriodDuration) { + revert InvalidEpochConfiguration( + 5, + nominationPeriodDuration, + settings.nominationPeriodDuration + ); + } else if (votingPeriodDuration < settings.votingPeriodDuration) { + revert InvalidEpochConfiguration( + 6, + votingPeriodDuration, + settings.votingPeriodDuration + ); + } + } + + function configureEpochSchedule( + Data storage self, + Epoch.Data storage epoch, + uint64 epochStartDate, + uint64 nominationPeriodStartDate, + uint64 votingPeriodStartDate, + uint64 epochEndDate + ) internal { + validateEpochSchedule( + self, + epochStartDate, + nominationPeriodStartDate, + votingPeriodStartDate, + epochEndDate + ); + + epoch.setEpochDates( + epochStartDate, + nominationPeriodStartDate, + votingPeriodStartDate, + epochEndDate + ); + } + + /// @dev Changes epoch dates, with validations + function validateEpochScheduleTweak( + Data storage self, + Epoch.Data storage currentEpoch, + Epoch.Data memory newEpoch + ) internal view { + ElectionSettings.Data storage settings = getCurrentElectionSettings(self); + + if ( + uint64AbsDifference(newEpoch.endDate, currentEpoch.startDate + settings.epochDuration) > + settings.maxDateAdjustmentTolerance || + uint64AbsDifference( + newEpoch.nominationPeriodStartDate, + currentEpoch.nominationPeriodStartDate + ) > + settings.maxDateAdjustmentTolerance || + uint64AbsDifference( + newEpoch.votingPeriodStartDate, + currentEpoch.votingPeriodStartDate + ) > + settings.maxDateAdjustmentTolerance + ) { + revert InvalidEpochConfiguration(7, 0, 0); + } + + validateEpochSchedule( + self, + currentEpoch.startDate, + newEpoch.nominationPeriodStartDate, + newEpoch.votingPeriodStartDate, + newEpoch.endDate + ); + + if (Epoch.getPeriodFor(newEpoch) != Epoch.ElectionPeriod.Administration) { + revert ChangesCurrentPeriod(); + } + } + + /// @dev Moves schedule forward to immediately jump to the nomination period + function jumpToNominationPeriod(Data storage self) internal { + Epoch.Data storage currentEpoch = getCurrentEpoch(self); + ElectionSettings.Data storage settings = getCurrentElectionSettings(self); + + // Keep the previous durations, but shift everything back + // so that nominations start now + uint64 newNominationPeriodStartDate = SafeCastU256.to64(block.timestamp); + uint64 newVotingPeriodStartDate = newNominationPeriodStartDate + + settings.nominationPeriodDuration; + uint64 newEpochEndDate = newVotingPeriodStartDate + settings.votingPeriodDuration; + + configureEpochSchedule( + self, + currentEpoch, + currentEpoch.startDate, + newNominationPeriodStartDate, + newVotingPeriodStartDate, + newEpochEndDate + ); + } + + function uint64AbsDifference(uint64 valueA, uint64 valueB) private pure returns (uint64) { + return valueA > valueB ? valueA - valueB : valueB - valueA; } } diff --git a/protocol/governance/contracts/storage/CouncilMembers.sol b/protocol/governance/contracts/storage/CouncilMembers.sol new file mode 100644 index 0000000000..679826c073 --- /dev/null +++ b/protocol/governance/contracts/storage/CouncilMembers.sol @@ -0,0 +1,23 @@ +//SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {SetUtil} from "@synthetixio/core-contracts/contracts/utils/SetUtil.sol"; + +library CouncilMembers { + bytes32 private constant _STORAGE_SLOT = + keccak256(abi.encode("io.synthetix.governance.CouncilMembers")); + + struct Data { + // The address of the council NFT + address councilToken; + // Council member addresses + SetUtil.AddressSet councilMembers; + } + + function load() internal pure returns (Data storage store) { + bytes32 s = _STORAGE_SLOT; + assembly { + store.slot := s + } + } +} diff --git a/protocol/governance/contracts/storage/CrossChainDebtShare.sol b/protocol/governance/contracts/storage/CrossChainDebtShare.sol deleted file mode 100644 index fcd1dfcad8..0000000000 --- a/protocol/governance/contracts/storage/CrossChainDebtShare.sol +++ /dev/null @@ -1,16 +0,0 @@ -//SPDX-License-Identifier: MIT -pragma solidity ^0.8.0; - -import "@synthetixio/core-contracts/contracts/utils/SetUtil.sol"; -import "../interfaces/IDebtShare.sol"; - -library CrossChainDebtShare { - struct Data { - // Synthetix v2 cross chain debt share merkle root - bytes32 merkleRoot; - // Cross chain debt share merkle root snapshot blocknumber - uint merkleRootBlockNumber; - // Cross chain debt shares declared on this chain - mapping(address => uint) debtShares; - } -} diff --git a/protocol/governance/contracts/storage/DebtShare.sol b/protocol/governance/contracts/storage/DebtShare.sol deleted file mode 100644 index a4e0672cda..0000000000 --- a/protocol/governance/contracts/storage/DebtShare.sol +++ /dev/null @@ -1,27 +0,0 @@ -//SPDX-License-Identifier: MIT -pragma solidity ^0.8.0; - -import "@synthetixio/core-contracts/contracts/utils/SetUtil.sol"; -import "../interfaces/IDebtShare.sol"; -import "./CrossChainDebtShare.sol"; - -library DebtShare { - bytes32 private constant _SLOT_DEBT_SHARE_STORAGE = - keccak256(abi.encode("io.synthetix.election-module.debtshare")); - - struct Data { - // Synthetix c2 DebtShare contract used to determine vote power in the local chain - IDebtShare debtShareContract; - // Array of debt share snapshot id's for each epoch - uint128[] debtShareIds; - // Array of CrossChainDebtShareData's for each epoch - CrossChainDebtShare.Data[] crossChainDebtShareData; - } - - function load() internal pure returns (Data storage debtShare) { - bytes32 s = _SLOT_DEBT_SHARE_STORAGE; - assembly { - debtShare.slot := s - } - } -} diff --git a/protocol/governance/contracts/storage/Election.sol b/protocol/governance/contracts/storage/Election.sol index 1502e95f02..22b466a52b 100644 --- a/protocol/governance/contracts/storage/Election.sol +++ b/protocol/governance/contracts/storage/Election.sol @@ -1,16 +1,12 @@ //SPDX-License-Identifier: MIT pragma solidity ^0.8.0; -import "@synthetixio/core-contracts/contracts/utils/SetUtil.sol"; - -import "./Ballot.sol"; -import "./Epoch.sol"; - -import "./ElectionSettings.sol"; +import {SetUtil} from "@synthetixio/core-contracts/contracts/utils/SetUtil.sol"; library Election { + using SetUtil for SetUtil.Bytes32Set; + struct Data { - Epoch.Data epoch; // True if ballots have been counted in this election bool evaluated; // True if NFTs have been re-shuffled in this election @@ -22,18 +18,13 @@ library Election { // List of winners of this election (requires evaluation) SetUtil.AddressSet winners; // List of all ballot ids in this election - bytes32[] ballotIds; - // BallotData by ballot id - mapping(bytes32 => Ballot.Data) ballotsById; - // Ballot id that each user voted on - mapping(address => bytes32) ballotIdsByAddress; - // Number of votes for each candidate - mapping(address => uint) candidateVotes; - ElectionSettings.Data settings; + SetUtil.Bytes32Set ballotPtrs; + // Total votes count for a given candidate + mapping(address => uint256) candidateVoteTotals; } - function load(uint id) internal pure returns (Data storage election) { - bytes32 s = keccak256(abi.encode("io.synthetix.synthetix.Election", id)); + function load(uint epochIndex) internal pure returns (Data storage election) { + bytes32 s = keccak256(abi.encode("io.synthetix.governance.Election", epochIndex)); assembly { election.slot := s } diff --git a/protocol/governance/contracts/storage/ElectionSettings.sol b/protocol/governance/contracts/storage/ElectionSettings.sol index 13671f54c2..f80c17778d 100644 --- a/protocol/governance/contracts/storage/ElectionSettings.sol +++ b/protocol/governance/contracts/storage/ElectionSettings.sol @@ -1,23 +1,110 @@ //SPDX-License-Identifier: MIT pragma solidity ^0.8.0; -import "@synthetixio/core-contracts/contracts/utils/SetUtil.sol"; - library ElectionSettings { + event ElectionSettingsUpdated( + uint8 epochSeatCount, + uint8 minimumActiveMembers, + uint64 epochDuration, + uint64 nominationPeriodDuration, + uint64 votingPeriodDuration, + uint64 maxDateAdjustmentTolerance + ); + error InvalidElectionSettings(); + struct Data { - // Number of council members in the next epoch - uint8 nextEpochSeatCount; + // Number of council members in the current epoch + uint8 epochSeatCount; // Minimum active council members. If too many are dismissed an emergency election is triggered uint8 minimumActiveMembers; - // Minimum epoch duration when adjusting schedules - uint64 minEpochDuration; - // Minimum nomination period duration when adjusting schedules - uint64 minNominationPeriodDuration; - // Minimum voting period duration when adjusting schedules - uint64 minVotingPeriodDuration; + // Expected duration of the epoch + uint64 epochDuration; + // Expected nomination period duration + uint64 nominationPeriodDuration; + // Expected voting period duration + uint64 votingPeriodDuration; // Maximum size for tweaking epoch schedules (see tweakEpochSchedule) uint64 maxDateAdjustmentTolerance; - // Default batch size when calling evaluate() with numBallots = 0 - uint defaultBallotEvaluationBatchSize; + } + + function load(uint epochIndex) internal pure returns (Data storage settings) { + bytes32 s = keccak256(abi.encode("io.synthetix.governance.ElectionSettings", epochIndex)); + assembly { + settings.slot := s + } + } + + /// @dev Minimum duration for Nomination and Voting periods, making sure that + /// they cannot be "deleted" by the current council + uint64 private constant _MIN_ELECTION_PERIOD_DURATION = 1 days; + + function setElectionSettings( + Data storage settings, + uint8 epochSeatCount, + uint8 minimumActiveMembers, + uint64 epochDuration, + uint64 nominationPeriodDuration, + uint64 votingPeriodDuration, + uint64 maxDateAdjustmentTolerance + ) internal { + settings.epochSeatCount = epochSeatCount; + settings.minimumActiveMembers = minimumActiveMembers; + settings.epochDuration = epochDuration; + settings.nominationPeriodDuration = nominationPeriodDuration; + settings.votingPeriodDuration = votingPeriodDuration; + settings.maxDateAdjustmentTolerance = maxDateAdjustmentTolerance; + + validate(settings); + + emit ElectionSettingsUpdated( + settings.epochSeatCount, + settings.minimumActiveMembers, + settings.epochDuration, + settings.nominationPeriodDuration, + settings.votingPeriodDuration, + settings.maxDateAdjustmentTolerance + ); + } + + function validate(Data storage settings) internal view { + if ( + settings.epochSeatCount == 0 || + settings.minimumActiveMembers == 0 || + settings.minimumActiveMembers > settings.epochSeatCount || + settings.epochDuration == 0 || + settings.nominationPeriodDuration == 0 || + settings.votingPeriodDuration == 0 || + settings.nominationPeriodDuration < minimumElectionPeriodDuration(settings) || + settings.votingPeriodDuration < minimumElectionPeriodDuration(settings) || + settings.epochDuration < + settings.nominationPeriodDuration + settings.votingPeriodDuration + ) { + revert InvalidElectionSettings(); + } + } + + function minimumElectionPeriodDuration(Data storage settings) internal view returns (uint) { + return _MIN_ELECTION_PERIOD_DURATION + settings.maxDateAdjustmentTolerance; + } + + function copyMissingFrom(Data storage to, Data storage from) internal { + if (to.epochSeatCount == 0) { + to.epochSeatCount = from.epochSeatCount; + } + if (to.minimumActiveMembers == 0) { + to.minimumActiveMembers = from.minimumActiveMembers; + } + if (to.epochDuration == 0) { + to.epochDuration = from.epochDuration; + } + if (to.nominationPeriodDuration == 0) { + to.nominationPeriodDuration = from.nominationPeriodDuration; + } + if (to.votingPeriodDuration == 0) { + to.votingPeriodDuration = from.votingPeriodDuration; + } + if (to.maxDateAdjustmentTolerance == 0) { + to.maxDateAdjustmentTolerance = from.maxDateAdjustmentTolerance; + } } } diff --git a/protocol/governance/contracts/storage/Epoch.sol b/protocol/governance/contracts/storage/Epoch.sol index 5fef549400..3cd14c3a23 100644 --- a/protocol/governance/contracts/storage/Epoch.sol +++ b/protocol/governance/contracts/storage/Epoch.sol @@ -1,17 +1,87 @@ //SPDX-License-Identifier: MIT pragma solidity ^0.8.0; -import "@synthetixio/core-contracts/contracts/utils/SetUtil.sol"; +import {SafeCastU256} from "@synthetixio/core-contracts/contracts/utils/SafeCast.sol"; library Epoch { + using SafeCastU256 for uint256; + + enum ElectionPeriod { + // Council elected and active + Administration, + // Accepting nominations for next election + Nomination, + // Accepting votes for ongoing election + Vote, + // Votes being counted + Evaluation + } + struct Data { // Date at which the epoch started uint64 startDate; - // Date at which the epoch's voting period will end - uint64 endDate; // Date at which the epoch's nomination period will start uint64 nominationPeriodStartDate; // Date at which the epoch's voting period will start uint64 votingPeriodStartDate; + // Date at which the epoch's voting period will end + uint64 endDate; + } + + function load(uint epochIndex) internal pure returns (Data storage epoch) { + bytes32 s = keccak256(abi.encode("io.synthetix.governance.Epoch", epochIndex)); + assembly { + epoch.slot := s + } + } + + function setEpochDates( + Epoch.Data storage self, + uint64 startDate, + uint64 nominationPeriodStartDate, + uint64 votingPeriodStartDate, + uint64 endDate + ) internal { + self.startDate = startDate; + self.nominationPeriodStartDate = nominationPeriodStartDate; + self.votingPeriodStartDate = votingPeriodStartDate; + self.endDate = endDate; + } + + /// @dev Determines the current period type according to the current time and the epoch's dates + function getCurrentPeriod(Data storage epoch) internal view returns (Epoch.ElectionPeriod) { + uint64 currentTime = block.timestamp.to64(); + + if (currentTime >= epoch.endDate) { + return ElectionPeriod.Evaluation; + } + + if (currentTime >= epoch.votingPeriodStartDate) { + return ElectionPeriod.Vote; + } + + if (currentTime >= epoch.nominationPeriodStartDate) { + return ElectionPeriod.Nomination; + } + + return ElectionPeriod.Administration; + } + + function getPeriodFor(Data memory epoch) internal view returns (Epoch.ElectionPeriod) { + uint64 currentTime = block.timestamp.to64(); + + if (currentTime >= epoch.endDate) { + return ElectionPeriod.Evaluation; + } + + if (currentTime >= epoch.votingPeriodStartDate) { + return ElectionPeriod.Vote; + } + + if (currentTime >= epoch.nominationPeriodStartDate) { + return ElectionPeriod.Nomination; + } + + return ElectionPeriod.Administration; } } diff --git a/protocol/governance/contracts/storage/SnapshotVotePower.sol b/protocol/governance/contracts/storage/SnapshotVotePower.sol new file mode 100644 index 0000000000..865297e933 --- /dev/null +++ b/protocol/governance/contracts/storage/SnapshotVotePower.sol @@ -0,0 +1,20 @@ +//SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {SnapshotVotePowerEpoch} from "./SnapshotVotePowerEpoch.sol"; + +library SnapshotVotePower { + struct Data { + bool enabled; + mapping(uint128 => SnapshotVotePowerEpoch.Data) epochs; + } + + function load(address snapshotContract) internal pure returns (Data storage self) { + bytes32 s = keccak256( + abi.encode("io.synthetix.governance.SnapshotVotePower", snapshotContract) + ); + assembly { + self.slot := s + } + } +} diff --git a/protocol/governance/contracts/storage/SnapshotVotePowerEpoch.sol b/protocol/governance/contracts/storage/SnapshotVotePowerEpoch.sol new file mode 100644 index 0000000000..46be10cfde --- /dev/null +++ b/protocol/governance/contracts/storage/SnapshotVotePowerEpoch.sol @@ -0,0 +1,9 @@ +//SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +library SnapshotVotePowerEpoch { + struct Data { + uint128 snapshotId; + mapping(address => uint256) recordedVotingPower; + } +} diff --git a/protocol/governance/contracts/submodules/election/CrossChainDebtShareManager.sol b/protocol/governance/contracts/submodules/election/CrossChainDebtShareManager.sol deleted file mode 100644 index cfc60a820a..0000000000 --- a/protocol/governance/contracts/submodules/election/CrossChainDebtShareManager.sol +++ /dev/null @@ -1,83 +0,0 @@ -//SPDX-License-Identifier: MIT -pragma solidity ^0.8.0; - -import "@synthetixio/core-contracts/contracts/utils/MerkleProof.sol"; -import "./ElectionBase.sol"; -import "../../storage/DebtShare.sol"; - -/// @dev Uses a merkle tree to track user Synthetix v2 debt shares on other chains at a particular block number -contract CrossChainDebtShareManager is ElectionBase { - error MerkleRootNotSet(); - error InvalidMerkleProof(); - error CrossChainDebtShareAlreadyDeclared(); - - event CrossChainDebtShareMerkleRootSet(bytes32 merkleRoot, uint blocknumber, uint epoch); - event CrossChainDebtShareDeclared(address user, uint debtShare); - - function _setCrossChainDebtShareMerkleRoot(bytes32 merkleRoot, uint blocknumber) internal { - CrossChainDebtShare.Data storage debtShareData = DebtShare.load().crossChainDebtShareData[ - Council.load().lastElectionId - ]; - - debtShareData.merkleRoot = merkleRoot; - debtShareData.merkleRootBlockNumber = blocknumber; - } - - function _declareCrossChainDebtShare( - address user, - uint256 debtShare, - bytes32[] calldata merkleProof - ) internal { - CrossChainDebtShare.Data storage debtShareData = DebtShare.load().crossChainDebtShareData[ - Council.load().lastElectionId - ]; - - if (debtShareData.debtShares[user] != 0) { - revert CrossChainDebtShareAlreadyDeclared(); - } - - if (debtShareData.merkleRoot == 0) { - revert MerkleRootNotSet(); - } - - bytes32 leaf = keccak256(abi.encodePacked(user, debtShare)); - - if (!MerkleProof.verify(merkleProof, debtShareData.merkleRoot, leaf)) { - revert InvalidMerkleProof(); - } - - debtShareData.debtShares[user] = debtShare; - } - - function _getCrossChainDebtShareMerkleRoot() internal view returns (bytes32) { - CrossChainDebtShare.Data storage debtShareData = DebtShare.load().crossChainDebtShareData[ - Council.load().lastElectionId - ]; - - if (debtShareData.merkleRoot == 0) { - revert MerkleRootNotSet(); - } - - return debtShareData.merkleRoot; - } - - function _getCrossChainDebtShareMerkleRootBlockNumber() internal view returns (uint) { - CrossChainDebtShare.Data storage debtShareData = DebtShare.load().crossChainDebtShareData[ - Council.load().lastElectionId - ]; - - if (debtShareData.merkleRoot == 0) { - revert MerkleRootNotSet(); - } - - return debtShareData.merkleRootBlockNumber; - } - - function _getDeclaredCrossChainDebtShare(address user) internal view returns (uint) { - CrossChainDebtShare.Data storage debtShareData = DebtShare.load().crossChainDebtShareData[ - Council.load().lastElectionId - ]; - - return debtShareData.debtShares[user]; - } -} diff --git a/protocol/governance/contracts/submodules/election/DebtShareManager.sol b/protocol/governance/contracts/submodules/election/DebtShareManager.sol deleted file mode 100644 index 8becaa4c61..0000000000 --- a/protocol/governance/contracts/submodules/election/DebtShareManager.sol +++ /dev/null @@ -1,67 +0,0 @@ -//SPDX-License-Identifier: MIT -pragma solidity ^0.8.0; - -import "../../storage/DebtShare.sol"; -import "@synthetixio/core-contracts/contracts/utils/AddressUtil.sol"; -import "@synthetixio/core-contracts/contracts/errors/ChangeError.sol"; -import "@synthetixio/core-contracts/contracts/errors/AddressError.sol"; -import "@synthetixio/core-contracts/contracts/utils/SafeCast.sol"; - -import "./ElectionBase.sol"; - -/// @dev Tracks user Synthetix v2 debt chains on the local chain at a particular block number -contract DebtShareManager is ElectionBase { - using SafeCastU256 for uint256; - - error DebtShareContractNotSet(); - error DebtShareSnapshotIdNotSet(); - - event DebtShareContractSet(address contractAddress); - event DebtShareSnapshotIdSet(uint snapshotId); - - function _setDebtShareSnapshotId(uint snapshotId) internal { - DebtShare.Data storage store = DebtShare.load(); - - uint currentEpochIndex = Council.load().lastElectionId; - store.debtShareIds[currentEpochIndex] = snapshotId.to128(); - - emit DebtShareSnapshotIdSet(snapshotId); - } - - function _getDebtShareSnapshotId() internal view returns (uint) { - DebtShare.Data storage store = DebtShare.load(); - - uint128 debtShareId = store.debtShareIds[Council.load().lastElectionId]; - if (debtShareId == 0) { - revert DebtShareSnapshotIdNotSet(); - } - - return debtShareId; - } - - function _setDebtShareContract(address newDebtShareContractAddress) internal { - DebtShare.Data storage store = DebtShare.load(); - - if (newDebtShareContractAddress == address(0)) { - revert AddressError.ZeroAddress(); - } - - if (newDebtShareContractAddress == address(store.debtShareContract)) { - revert ChangeError.NoChange(); - } - - if (!AddressUtil.isContract(newDebtShareContractAddress)) { - revert AddressError.NotAContract(newDebtShareContractAddress); - } - - store.debtShareContract = IDebtShare(newDebtShareContractAddress); - } - - function _getDebtShare(address user) internal view returns (uint) { - DebtShare.Data storage store = DebtShare.load(); - - uint128 debtShareId = store.debtShareIds[Council.load().lastElectionId]; - - return store.debtShareContract.balanceOfOnPeriod(user, debtShareId); - } -} diff --git a/protocol/governance/contracts/submodules/election/ElectionBase.sol b/protocol/governance/contracts/submodules/election/ElectionBase.sol deleted file mode 100644 index 3fd3b59d16..0000000000 --- a/protocol/governance/contracts/submodules/election/ElectionBase.sol +++ /dev/null @@ -1,71 +0,0 @@ -//SPDX-License-Identifier: MIT -pragma solidity ^0.8.0; - -import "../../storage/Council.sol"; -import "../../storage/Election.sol"; - -/// @dev Common utils, errors, and events to be used by any contracts that conform the ElectionModule -contract ElectionBase { - // --------------------------------------- - // Errors - // --------------------------------------- - - error ElectionNotEvaluated(); - error ElectionAlreadyEvaluated(); - error AlreadyNominated(); - error NotNominated(); - error NoCandidates(); - error NoVotePower(); - error VoteNotCasted(); - error DuplicateCandidates(); - error InvalidEpochConfiguration(); - error InvalidElectionSettings(); - error NotCallableInCurrentPeriod(); - error ChangesCurrentPeriod(); - error AlreadyACouncilMember(); - error NotACouncilMember(); - error InvalidMinimumActiveMembers(); - - // --------------------------------------- - // Events - // --------------------------------------- - - event ElectionModuleInitialized(); - event EpochStarted(uint epochIndex); - event CouncilTokenCreated(address proxy, address implementation); - event CouncilTokenUpgraded(address newImplementation); - event CouncilMemberAdded(address indexed member, uint indexed epochIndex); - event CouncilMemberRemoved(address indexed member, uint indexed epochIndex); - event CouncilMembersDismissed(address[] members, uint indexed epochIndex); - event EpochScheduleUpdated( - uint64 nominationPeriodStartDate, - uint64 votingPeriodStartDate, - uint64 epochEndDate - ); - event MinimumEpochDurationsChanged( - uint64 minNominationPeriodDuration, - uint64 minVotingPeriodDuration, - uint64 minEpochDuration - ); - event MaxDateAdjustmentToleranceChanged(uint64 tolerance); - event DefaultBallotEvaluationBatchSizeChanged(uint size); - event NextEpochSeatCountChanged(uint8 seatCount); - event MinimumActiveMembersChanged(uint8 minimumActiveMembers); - event CandidateNominated(address indexed candidate, uint indexed epochIndex); - event NominationWithdrawn(address indexed candidate, uint indexed epochIndex); - event VoteRecorded( - address indexed voter, - bytes32 indexed ballotId, - uint indexed epochIndex, - uint votePower - ); - event VoteWithdrawn( - address indexed voter, - bytes32 indexed ballotId, - uint indexed epochIndex, - uint votePower - ); - event ElectionEvaluated(uint indexed epochIndex, uint totalBallots); - event ElectionBatchEvaluated(uint indexed epochIndex, uint evaluatedBallots, uint totalBallots); - event EmergencyElectionStarted(uint indexed epochIndex); -} diff --git a/protocol/governance/contracts/submodules/election/ElectionCredentials.sol b/protocol/governance/contracts/submodules/election/ElectionCredentials.sol index 955002cb36..9622286aed 100644 --- a/protocol/governance/contracts/submodules/election/ElectionCredentials.sol +++ b/protocol/governance/contracts/submodules/election/ElectionCredentials.sol @@ -1,53 +1,63 @@ //SPDX-License-Identifier: MIT pragma solidity ^0.8.0; -import "@synthetixio/core-contracts/contracts/proxy/UUPSProxy.sol"; -import "@synthetixio/core-contracts/contracts/errors/ArrayError.sol"; -import "./ElectionBase.sol"; - -import "@synthetixio/core-modules/contracts/storage/AssociatedSystem.sol"; +import {ArrayError} from "@synthetixio/core-contracts/contracts/errors/ArrayError.sol"; +import {SetUtil} from "@synthetixio/core-contracts/contracts/utils/SetUtil.sol"; +import {AssociatedSystem} from "@synthetixio/core-modules/contracts/storage/AssociatedSystem.sol"; +import {CouncilMembers} from "../../storage/CouncilMembers.sol"; /// @dev Core functionality for keeping track of council members with an NFT token -contract ElectionCredentials is ElectionBase { +contract ElectionCredentials { using SetUtil for SetUtil.AddressSet; - using Council for Council.Data; + using CouncilMembers for CouncilMembers.Data; using AssociatedSystem for AssociatedSystem.Data; + event CouncilMemberAdded(address indexed member, uint256 indexed epochIndex); + event CouncilMemberRemoved(address indexed member, uint256 indexed epochIndex); + + error AlreadyACouncilMember(); + error NotACouncilMember(); + bytes32 internal constant _COUNCIL_NFT_SYSTEM = "councilToken"; - function _removeAllCouncilMembers(uint epochIndex) internal { - SetUtil.AddressSet storage members = Council.load().councilMembers; + function _removeAllCouncilMembers(uint256 epochIndex) internal { + SetUtil.AddressSet storage members = CouncilMembers.load().councilMembers; - uint numMembers = members.length(); + uint256 numMembers = members.length(); - for (uint memberIndex = 0; memberIndex < numMembers; memberIndex++) { + for (uint256 memberIndex = 0; memberIndex < numMembers; memberIndex++) { // Always removes the first element in the array // until none are left. _removeCouncilMember(members.valueAt(1), epochIndex); } } - function _addCouncilMembers(address[] memory membersToAdd, uint epochIndex) internal { - uint numMembers = membersToAdd.length; + function _addCouncilMembers(address[] memory membersToAdd, uint256 epochIndex) internal { + uint256 numMembers = membersToAdd.length; if (numMembers == 0) revert ArrayError.EmptyArray(); - for (uint memberIndex = 0; memberIndex < numMembers; memberIndex++) { - _addCouncilMember(membersToAdd[memberIndex], epochIndex); + CouncilMembers.Data storage store = CouncilMembers.load(); + + for (uint256 memberIndex = 0; memberIndex < numMembers; memberIndex++) { + _addCouncilMember(store, membersToAdd[memberIndex], epochIndex); } } - function _removeCouncilMembers(address[] memory membersToRemove, uint epochIndex) internal { - uint numMembers = membersToRemove.length; + function _removeCouncilMembers(address[] memory membersToRemove, uint256 epochIndex) internal { + uint256 numMembers = membersToRemove.length; if (numMembers == 0) revert ArrayError.EmptyArray(); - for (uint memberIndex = 0; memberIndex < numMembers; memberIndex++) { + for (uint256 memberIndex = 0; memberIndex < numMembers; memberIndex++) { _removeCouncilMember(membersToRemove[memberIndex], epochIndex); } } - function _addCouncilMember(address newMember, uint epochIndex) internal { - Council.Data storage store = Council.load(); + function _addCouncilMember( + CouncilMembers.Data storage store, + address newMember, + uint256 epochIndex + ) internal { SetUtil.AddressSet storage members = store.councilMembers; if (members.contains(newMember)) { @@ -56,17 +66,14 @@ contract ElectionCredentials is ElectionBase { members.add(newMember); - // Note that tokenId = 0 will not be used. - uint tokenId = members.length(); + uint256 tokenId = _getCouncilMemberTokenId(newMember); AssociatedSystem.load(_COUNCIL_NFT_SYSTEM).asNft().mint(newMember, tokenId); - store.councilTokenIds[newMember] = tokenId; - emit CouncilMemberAdded(newMember, epochIndex); } - function _removeCouncilMember(address member, uint epochIndex) internal { - Council.Data storage store = Council.load(); + function _removeCouncilMember(address member, uint256 epochIndex) internal { + CouncilMembers.Data storage store = CouncilMembers.load(); SetUtil.AddressSet storage members = store.councilMembers; if (!members.contains(member)) { @@ -75,24 +82,15 @@ contract ElectionCredentials is ElectionBase { members.remove(member); - uint tokenId = _getCouncilMemberTokenId(member); + uint256 tokenId = _getCouncilMemberTokenId(member); AssociatedSystem.load(_COUNCIL_NFT_SYSTEM).asNft().burn(tokenId); - // tokenId = 0 means no associated token. - store.councilTokenIds[member] = 0; - emit CouncilMemberRemoved(member, epochIndex); } - function _getCouncilToken() private view returns (IERC721) { - return AssociatedSystem.load(_COUNCIL_NFT_SYSTEM).asNft(); - } - - function _getCouncilMemberTokenId(address member) private view returns (uint) { - uint tokenId = Council.load().councilTokenIds[member]; - - if (tokenId == 0) revert NotACouncilMember(); - - return tokenId; + /// @dev cast member address to uint256 to use as tokenId + function _getCouncilMemberTokenId(address member) private pure returns (uint) { + // solhint-disable-next-line numcast/safe-cast + return uint256(uint160(member)); } } diff --git a/protocol/governance/contracts/submodules/election/ElectionSchedule.sol b/protocol/governance/contracts/submodules/election/ElectionSchedule.sol deleted file mode 100644 index 0faf308678..0000000000 --- a/protocol/governance/contracts/submodules/election/ElectionSchedule.sol +++ /dev/null @@ -1,193 +0,0 @@ -//SPDX-License-Identifier: MIT -pragma solidity ^0.8.0; - -import "./ElectionBase.sol"; -import "@synthetixio/core-contracts/contracts/errors/InitError.sol"; -import "@synthetixio/core-contracts/contracts/utils/SafeCast.sol"; - -/// @dev Provides core schedule functionality. I.e. dates, periods, etc -contract ElectionSchedule is ElectionBase { - using Council for Council.Data; - using SafeCastU256 for uint256; - - /// @dev Used to allow certain functions to only operate within a given period - modifier onlyInPeriod(Council.ElectionPeriod period) { - if (Council.load().getCurrentPeriod() != period) { - revert NotCallableInCurrentPeriod(); - } - - _; - } - - /// @dev Sets dates within an epoch, with validations - function _configureEpochSchedule( - Epoch.Data storage epoch, - uint64 epochStartDate, - uint64 nominationPeriodStartDate, - uint64 votingPeriodStartDate, - uint64 epochEndDate - ) internal { - _validateEpochSchedule( - epochStartDate, - nominationPeriodStartDate, - votingPeriodStartDate, - epochEndDate - ); - - epoch.startDate = epochStartDate; - epoch.nominationPeriodStartDate = nominationPeriodStartDate; - epoch.votingPeriodStartDate = votingPeriodStartDate; - epoch.endDate = epochEndDate; - } - - /// @dev Ensures epoch dates are in the correct order, durations are above minimums, etc - function _validateEpochSchedule( - uint64 epochStartDate, - uint64 nominationPeriodStartDate, - uint64 votingPeriodStartDate, - uint64 epochEndDate - ) private view { - if ( - epochEndDate <= votingPeriodStartDate || - votingPeriodStartDate <= nominationPeriodStartDate || - nominationPeriodStartDate <= epochStartDate - ) { - revert InvalidEpochConfiguration(); - } - - uint64 epochDuration = epochEndDate - epochStartDate; - uint64 votingPeriodDuration = epochEndDate - votingPeriodStartDate; - uint64 nominationPeriodDuration = votingPeriodStartDate - nominationPeriodStartDate; - - ElectionSettings.Data storage settings = Council.load().nextElectionSettings; - - if ( - epochDuration < settings.minEpochDuration || - nominationPeriodDuration < settings.minNominationPeriodDuration || - votingPeriodDuration < settings.minVotingPeriodDuration - ) { - revert InvalidEpochConfiguration(); - } - } - - /// @dev Changes epoch dates, with validations - function _adjustEpochSchedule( - Epoch.Data storage epoch, - uint64 newNominationPeriodStartDate, - uint64 newVotingPeriodStartDate, - uint64 newEpochEndDate, - bool ensureChangesAreSmall - ) internal { - uint64 maxDateAdjustmentTolerance = Council - .load() - .nextElectionSettings - .maxDateAdjustmentTolerance; - - if (ensureChangesAreSmall) { - if ( - _uint64AbsDifference(newEpochEndDate, epoch.endDate) > maxDateAdjustmentTolerance || - _uint64AbsDifference( - newNominationPeriodStartDate, - epoch.nominationPeriodStartDate - ) > - maxDateAdjustmentTolerance || - _uint64AbsDifference(newVotingPeriodStartDate, epoch.votingPeriodStartDate) > - maxDateAdjustmentTolerance - ) { - revert InvalidEpochConfiguration(); - } - } - - _configureEpochSchedule( - epoch, - epoch.startDate, - newNominationPeriodStartDate, - newVotingPeriodStartDate, - newEpochEndDate - ); - - if (Council.load().getCurrentPeriod() != Council.ElectionPeriod.Administration) { - revert ChangesCurrentPeriod(); - } - } - - /// @dev Moves schedule forward to immediately jump to the nomination period - function _jumpToNominationPeriod() internal { - Epoch.Data storage currentEpoch = Council.load().getCurrentElection().epoch; - - uint64 nominationPeriodDuration = _getNominationPeriodDuration(currentEpoch); - uint64 votingPeriodDuration = _getVotingPeriodDuration(currentEpoch); - - // Keep the previous durations, but shift everything back - // so that nominations start now - uint64 newNominationPeriodStartDate = block.timestamp.to64(); - uint64 newVotingPeriodStartDate = newNominationPeriodStartDate + nominationPeriodDuration; - uint64 newEpochEndDate = newVotingPeriodStartDate + votingPeriodDuration; - - _configureEpochSchedule( - currentEpoch, - currentEpoch.startDate, - newNominationPeriodStartDate, - newVotingPeriodStartDate, - newEpochEndDate - ); - } - - /// @dev Copies the current epoch schedule to the next epoch, maintaining durations - function _copyScheduleFromPreviousEpoch() internal { - Epoch.Data storage previousEpoch = Council.load().getPreviousElection().epoch; - Epoch.Data storage currentEpoch = Council.load().getCurrentElection().epoch; - - uint64 currentEpochStartDate = block.timestamp.to64(); - uint64 currentEpochEndDate = currentEpochStartDate + _getEpochDuration(previousEpoch); - uint64 currentVotingPeriodStartDate = currentEpochEndDate - - _getVotingPeriodDuration(previousEpoch); - uint64 currentNominationPeriodStartDate = currentVotingPeriodStartDate - - _getNominationPeriodDuration(previousEpoch); - - _configureEpochSchedule( - currentEpoch, - currentEpochStartDate, - currentNominationPeriodStartDate, - currentVotingPeriodStartDate, - currentEpochEndDate - ); - } - - /// @dev Sets the minimum epoch durations, with validations - function _setMinEpochDurations( - uint64 newMinNominationPeriodDuration, - uint64 newMinVotingPeriodDuration, - uint64 newMinEpochDuration - ) internal { - ElectionSettings.Data storage settings = Council.load().nextElectionSettings; - - if ( - newMinNominationPeriodDuration == 0 || - newMinVotingPeriodDuration == 0 || - newMinEpochDuration == 0 - ) { - revert InvalidElectionSettings(); - } - - settings.minNominationPeriodDuration = newMinNominationPeriodDuration; - settings.minVotingPeriodDuration = newMinVotingPeriodDuration; - settings.minEpochDuration = newMinEpochDuration; - } - - function _uint64AbsDifference(uint64 valueA, uint64 valueB) private pure returns (uint64) { - return valueA > valueB ? valueA - valueB : valueB - valueA; - } - - function _getEpochDuration(Epoch.Data storage epoch) private view returns (uint64) { - return epoch.endDate - epoch.startDate; - } - - function _getVotingPeriodDuration(Epoch.Data storage epoch) private view returns (uint64) { - return epoch.endDate - epoch.votingPeriodStartDate; - } - - function _getNominationPeriodDuration(Epoch.Data storage epoch) private view returns (uint64) { - return epoch.votingPeriodStartDate - epoch.nominationPeriodStartDate; - } -} diff --git a/protocol/governance/contracts/submodules/election/ElectionTally.sol b/protocol/governance/contracts/submodules/election/ElectionTally.sol index 45b32a35e8..469d6bfbb4 100644 --- a/protocol/governance/contracts/submodules/election/ElectionTally.sol +++ b/protocol/governance/contracts/submodules/election/ElectionTally.sol @@ -1,24 +1,30 @@ //SPDX-License-Identifier: MIT pragma solidity ^0.8.0; -import "./ElectionBase.sol"; - -import "../../storage/Council.sol"; +import {SetUtil} from "@synthetixio/core-contracts/contracts/utils/SetUtil.sol"; +import {Ballot} from "../../storage/Ballot.sol"; +import {Council} from "../../storage/Council.sol"; +import {Election} from "../../storage/Election.sol"; +import {ElectionSettings} from "../../storage/ElectionSettings.sol"; /// @dev Defines core vote-counting / ballot-processing functionality in ElectionModule.evaluate() -contract ElectionTally is ElectionBase { +contract ElectionTally { using SetUtil for SetUtil.AddressSet; - + using SetUtil for SetUtil.Bytes32Set; using Council for Council.Data; + uint16 private constant _DEFAULT_EVALUATION_BATCH_SIZE = 500; + function _evaluateNextBallotBatch(uint numBallots) internal { - Election.Data storage election = Council.load().getCurrentElection(); + Council.Data storage council = Council.load(); + Election.Data storage election = council.getCurrentElection(); + ElectionSettings.Data storage settings = council.getCurrentElectionSettings(); if (numBallots == 0) { - numBallots = election.settings.defaultBallotEvaluationBatchSize; + numBallots = _DEFAULT_EVALUATION_BATCH_SIZE; } - uint totalBallots = election.ballotIds.length; + uint totalBallots = election.ballotPtrs.length(); uint firstBallotIndex = election.numEvaluatedBallots; @@ -27,19 +33,24 @@ contract ElectionTally is ElectionBase { lastBallotIndex = totalBallots; } - _evaluateBallotRange(election, firstBallotIndex, lastBallotIndex); + _evaluateBallotRange(election, settings, firstBallotIndex, lastBallotIndex); } function _evaluateBallotRange( Election.Data storage election, + ElectionSettings.Data storage settings, uint fromIndex, uint toIndex ) private { - uint numSeats = election.settings.nextEpochSeatCount; + uint numSeats = settings.epochSeatCount; for (uint ballotIndex = fromIndex; ballotIndex < toIndex; ballotIndex++) { - bytes32 ballotId = election.ballotIds[ballotIndex]; - Ballot.Data storage ballot = election.ballotsById[ballotId]; + bytes32 ballotPtr = election.ballotPtrs.valueAt(ballotIndex + 1); + Ballot.Data storage ballot; + + assembly { + ballot.slot := ballotPtr + } _evaluateBallot(election, ballot, numSeats); } @@ -50,15 +61,14 @@ contract ElectionTally is ElectionBase { Ballot.Data storage ballot, uint numSeats ) internal { - uint ballotVotes = ballot.votes; + uint numCandidates = ballot.votedCandidates.length; - uint numCandidates = ballot.candidates.length; for (uint candidateIndex = 0; candidateIndex < numCandidates; candidateIndex++) { - address candidate = ballot.candidates[candidateIndex]; + address candidate = ballot.votedCandidates[candidateIndex]; - uint currentCandidateVotes = election.candidateVotes[candidate]; - uint newCandidateVotes = currentCandidateVotes + ballotVotes; - election.candidateVotes[candidate] = newCandidateVotes; + uint currentCandidateVotes = election.candidateVoteTotals[candidate]; + uint newCandidateVotes = currentCandidateVotes + ballot.amounts[candidateIndex]; + election.candidateVoteTotals[candidate] = newCandidateVotes; _updateWinnerSet(election, candidate, newCandidateVotes, numSeats); } @@ -106,7 +116,7 @@ contract ElectionTally is ElectionBase { for (uint8 winnerPosition = 1; winnerPosition <= numWinners; winnerPosition++) { address winner = winners.valueAt(winnerPosition); - uint winnerVotes = election.candidateVotes[winner]; + uint winnerVotes = election.candidateVoteTotals[winner]; if (winnerVotes < leastVotes) { leastVotes = winnerVotes; diff --git a/protocol/governance/contracts/submodules/election/ElectionVotes.sol b/protocol/governance/contracts/submodules/election/ElectionVotes.sol deleted file mode 100644 index f40b5950a6..0000000000 --- a/protocol/governance/contracts/submodules/election/ElectionVotes.sol +++ /dev/null @@ -1,110 +0,0 @@ -//SPDX-License-Identifier: MIT -pragma solidity ^0.8.0; - -import "./ElectionBase.sol"; -import "@synthetixio/core-contracts/contracts/utils/AddressUtil.sol"; -import "@synthetixio/core-contracts/contracts/utils/DecimalMath.sol"; -import "@synthetixio/core-contracts/contracts/errors/ChangeError.sol"; -import "@synthetixio/core-contracts/contracts/errors/AddressError.sol"; - -/// @dev Defines core functionality for recording votes in ElectionModule.cast() -contract ElectionVotes is ElectionBase { - using SetUtil for SetUtil.AddressSet; - - using Council for Council.Data; - using Election for Election.Data; - using Ballot for Ballot.Data; - - function _validateCandidates(address[] calldata candidates) internal virtual { - uint length = candidates.length; - - if (length == 0) { - revert NoCandidates(); - } - - SetUtil.AddressSet storage nominees = Council.load().getCurrentElection().nominees; - - for (uint i = 0; i < length; i++) { - address candidate = candidates[i]; - - // Reject candidates that are not nominated. - if (!nominees.contains(candidate)) { - revert NotNominated(); - } - - // Reject duplicate candidates. - if (i < length - 1) { - for (uint j = i + 1; j < length; j++) { - address otherCandidate = candidates[j]; - - if (candidate == otherCandidate) { - revert DuplicateCandidates(); - } - } - } - } - } - - function _recordVote( - address user, - uint votePower, - address[] calldata candidates - ) internal virtual returns (bytes32 ballotId) { - Election.Data storage election = Council.load().getCurrentElection(); - - ballotId = keccak256(abi.encode(candidates)); - Ballot.Data storage ballot = election.ballotsById[ballotId]; - - // Initialize ballot if new. - if (!ballot.isInitiated()) { - address[] memory newCandidates = candidates; - - ballot.candidates = newCandidates; - - election.ballotIds.push(ballotId); - } - - ballot.votes += votePower; - ballot.votesByUser[user] = votePower; - election.ballotIdsByAddress[user] = ballotId; - - return ballotId; - } - - function _withdrawVote( - address user, - uint votePower - ) internal virtual returns (bytes32 ballotId) { - Election.Data storage election = Council.load().getCurrentElection(); - - ballotId = election.ballotIdsByAddress[user]; - Ballot.Data storage ballot = election.ballotsById[ballotId]; - - ballot.votes -= votePower; - ballot.votesByUser[user] = 0; - election.ballotIdsByAddress[user] = bytes32(0); - - return ballotId; - } - - function _withdrawCastedVote(address user, uint epochIndex) internal virtual { - uint castedVotePower = _getCastedVotePower(user); - - bytes32 ballotId = _withdrawVote(user, castedVotePower); - - emit VoteWithdrawn(user, ballotId, epochIndex, castedVotePower); - } - - function _getCastedVotePower(address user) internal virtual returns (uint votePower) { - Election.Data storage election = Council.load().getCurrentElection(); - - bytes32 ballotId = election.ballotIdsByAddress[user]; - Ballot.Data storage ballot = election.ballotsById[ballotId]; - - return ballot.votesByUser[user]; - } - - function _getVotePower(address) internal view virtual returns (uint) { - return 1; - } -} diff --git a/protocol/governance/hardhat.config.ts b/protocol/governance/hardhat.config.ts index 53c90d93eb..00aad372f7 100644 --- a/protocol/governance/hardhat.config.ts +++ b/protocol/governance/hardhat.config.ts @@ -1,8 +1,9 @@ import commonConfig from '@synthetixio/common-config/hardhat.config'; - import 'solidity-docgen'; import { templates } from '@synthetixio/docgen'; +import './tasks/dev'; + const config = { ...commonConfig, solidity: { @@ -17,15 +18,10 @@ const config = { docgen: { exclude: [ './interfaces/external', - './interfaces/IUtilsModule.sol', - './errors', - './routers', './modules', - './mixins', './mocks', './storage', './submodules', - './utils', './Proxy.sol', ], templates, diff --git a/protocol/governance/package.json b/protocol/governance/package.json index a8b68669b9..74feec6d1c 100644 --- a/protocol/governance/package.json +++ b/protocol/governance/package.json @@ -1,16 +1,24 @@ { "name": "@synthetixio/governance", - "version": "2.0.0", + "version": "2.0.0-rc.0", "description": "On-Chain elections for all Synthetix councils", "private": true, "scripts": { - "clean": "hardhat clean", - "test1": "hardhat test test/unit/**/*.test.js", - "coverage1": "hardhat coverage --testfiles test/unit/**/*.test.js", + "clean": "hardhat clean && rm -rf test/generated contracts/generated", + "build": "yarn clean && hardhat storage:verify && hardhat generate-testable && hardhat cannon:build", + "build-testable": "yarn build cannonfile.test.toml ccip_mothership_chain_id=13370", + "check:storage": "git diff --exit-code storage.dump.sol", + "test": "yarn build-testable && hardhat test", + "coverage": "yarn build && hardhat coverage --network hardhat", "compile-contracts": "hardhat compile", - "size-contracts": "hardhat size-contracts", + "size-contracts": "hardhat compile && hardhat size-contracts", + "publish-contracts": "yarn build && cannon publish synthetix:$(node -p 'require(`./package.json`).version') --quiet", + "postpack": "yarn publish-contracts", "docgen": "hardhat docgen" }, + "keywords": [], + "author": "", + "license": "MIT", "devDependencies": { "@synthetixio/common-config": "workspace:*", "@synthetixio/core-contracts": "workspace:*", @@ -18,7 +26,9 @@ "@synthetixio/core-utils": "workspace:*", "@synthetixio/docgen": "workspace:*", "@synthetixio/router": "^3.3.0", + "@usecannon/cli": "^2.9.11", "hardhat": "^2.19.0", - "solidity-docgen": "^0.6.0-beta.36" + "solidity-docgen": "^0.6.0-beta.36", + "typechain": "^8.3.2" } } diff --git a/protocol/governance/storage.dump.sol b/protocol/governance/storage.dump.sol new file mode 100644 index 0000000000..0690950f78 --- /dev/null +++ b/protocol/governance/storage.dump.sol @@ -0,0 +1,316 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +// @custom:artifact @synthetixio/core-contracts/contracts/ownership/OwnableStorage.sol:OwnableStorage +library OwnableStorage { + bytes32 private constant _SLOT_OWNABLE_STORAGE = keccak256(abi.encode("io.synthetix.core-contracts.Ownable")); + struct Data { + address owner; + address nominatedOwner; + } + function load() internal pure returns (Data storage store) { + bytes32 s = _SLOT_OWNABLE_STORAGE; + assembly { + store.slot := s + } + } +} + +// @custom:artifact @synthetixio/core-contracts/contracts/proxy/ProxyStorage.sol:ProxyStorage +contract ProxyStorage { + bytes32 private constant _SLOT_PROXY_STORAGE = keccak256(abi.encode("io.synthetix.core-contracts.Proxy")); + struct ProxyStore { + address implementation; + bool simulatingUpgrade; + } + function _proxyStore() internal pure returns (ProxyStore storage store) { + bytes32 s = _SLOT_PROXY_STORAGE; + assembly { + store.slot := s + } + } +} + +// @custom:artifact @synthetixio/core-contracts/contracts/token/ERC721EnumerableStorage.sol:ERC721EnumerableStorage +library ERC721EnumerableStorage { + bytes32 private constant _SLOT_ERC721_ENUMERABLE_STORAGE = keccak256(abi.encode("io.synthetix.core-contracts.ERC721Enumerable")); + struct Data { + mapping(uint256 => uint256) ownedTokensIndex; + mapping(uint256 => uint256) allTokensIndex; + mapping(address => mapping(uint256 => uint256)) ownedTokens; + uint256[] allTokens; + } + function load() internal pure returns (Data storage store) { + bytes32 s = _SLOT_ERC721_ENUMERABLE_STORAGE; + assembly { + store.slot := s + } + } +} + +// @custom:artifact @synthetixio/core-contracts/contracts/token/ERC721Storage.sol:ERC721Storage +library ERC721Storage { + bytes32 private constant _SLOT_ERC721_STORAGE = keccak256(abi.encode("io.synthetix.core-contracts.ERC721")); + struct Data { + string name; + string symbol; + string baseTokenURI; + mapping(uint256 => address) ownerOf; + mapping(address => uint256) balanceOf; + mapping(uint256 => address) tokenApprovals; + mapping(address => mapping(address => bool)) operatorApprovals; + } + function load() internal pure returns (Data storage store) { + bytes32 s = _SLOT_ERC721_STORAGE; + assembly { + store.slot := s + } + } +} + +// @custom:artifact @synthetixio/core-contracts/contracts/utils/ERC2771Context.sol:ERC2771Context +library ERC2771Context { + address private constant TRUSTED_FORWARDER = 0xAE788aaf52780741E12BF79Ad684B91Bb0EF4D92; +} + +// @custom:artifact @synthetixio/core-contracts/contracts/utils/SetUtil.sol:SetUtil +library SetUtil { + struct UintSet { + Bytes32Set raw; + } + struct AddressSet { + Bytes32Set raw; + } + struct Bytes32Set { + bytes32[] _values; + mapping(bytes32 => uint) _positions; + } +} + +// @custom:artifact @synthetixio/core-modules/contracts/modules/NftModule.sol:NftModule +contract NftModule { + bytes32 internal constant _INITIALIZED_NAME = "NftModule"; +} + +// @custom:artifact @synthetixio/core-modules/contracts/storage/AssociatedSystem.sol:AssociatedSystem +library AssociatedSystem { + bytes32 public constant KIND_ERC20 = "erc20"; + bytes32 public constant KIND_ERC721 = "erc721"; + bytes32 public constant KIND_UNMANAGED = "unmanaged"; + struct Data { + address proxy; + address impl; + bytes32 kind; + } + function load(bytes32 id) internal pure returns (Data storage store) { + bytes32 s = keccak256(abi.encode("io.synthetix.core-modules.AssociatedSystem", id)); + assembly { + store.slot := s + } + } +} + +// @custom:artifact @synthetixio/core-modules/contracts/storage/CrossChain.sol:CrossChain +library CrossChain { + bytes32 private constant _SLOT_CROSS_CHAIN = keccak256(abi.encode("io.synthetix.core-modules.CrossChain")); + struct Data { + address ccipRouter; + SetUtil.UintSet supportedNetworks; + mapping(uint64 => uint64) ccipChainIdToSelector; + mapping(uint64 => uint64) ccipSelectorToChainId; + } + function load() internal pure returns (Data storage crossChain) { + bytes32 s = _SLOT_CROSS_CHAIN; + assembly { + crossChain.slot := s + } + } +} + +// @custom:artifact @synthetixio/core-modules/contracts/storage/Initialized.sol:Initialized +library Initialized { + struct Data { + bool initialized; + } + function load(bytes32 id) internal pure returns (Data storage store) { + bytes32 s = keccak256(abi.encode("io.synthetix.code-modules.Initialized", id)); + assembly { + store.slot := s + } + } +} + +// @custom:artifact @synthetixio/core-modules/contracts/utils/CcipClient.sol:CcipClient +library CcipClient { + bytes4 public constant EVM_EXTRA_ARGS_V1_TAG = 0x97a657c9; + struct EVMTokenAmount { + address token; + uint256 amount; + } + struct Any2EVMMessage { + bytes32 messageId; + uint64 sourceChainSelector; + bytes sender; + bytes data; + EVMTokenAmount[] tokenAmounts; + } + struct EVM2AnyMessage { + bytes receiver; + bytes data; + EVMTokenAmount[] tokenAmounts; + address feeToken; + bytes extraArgs; + } + struct EVMExtraArgsV1 { + uint256 gasLimit; + bool strict; + } +} + +// @custom:artifact contracts/modules/core/ElectionModule.sol:ElectionModule +contract ElectionModule { + uint256 private constant _CROSSCHAIN_GAS_LIMIT = 100000; + uint8 private constant _MAX_BALLOT_SIZE = 1; +} + +// @custom:artifact contracts/modules/core/ElectionModuleSatellite.sol:ElectionModuleSatellite +contract ElectionModuleSatellite { + uint256 private constant _CROSSCHAIN_GAS_LIMIT = 100000; +} + +// @custom:artifact contracts/storage/Ballot.sol:Ballot +library Ballot { + struct Data { + uint256 votingPower; + address[] votedCandidates; + uint256[] amounts; + } + function load(uint256 electionId, address voter, uint256 chainId) internal pure returns (Data storage self) { + bytes32 s = keccak256(abi.encode("io.synthetix.governance.Ballot", electionId, voter, chainId)); + assembly { + self.slot := s + } + } +} + +// @custom:artifact contracts/storage/Council.sol:Council +library Council { + bytes32 private constant _SLOT_COUNCIL_STORAGE = keccak256(abi.encode("io.synthetix.governance.Council")); + struct Data { + bool initialized; + uint256 currentElectionId; + } + function load() internal pure returns (Data storage store) { + bytes32 s = _SLOT_COUNCIL_STORAGE; + assembly { + store.slot := s + } + } +} + +// @custom:artifact contracts/storage/CouncilMembers.sol:CouncilMembers +library CouncilMembers { + bytes32 private constant _STORAGE_SLOT = keccak256(abi.encode("io.synthetix.governance.CouncilMembers")); + struct Data { + address councilToken; + SetUtil.AddressSet councilMembers; + } + function load() internal pure returns (Data storage store) { + bytes32 s = _STORAGE_SLOT; + assembly { + store.slot := s + } + } +} + +// @custom:artifact contracts/storage/Election.sol:Election +library Election { + struct Data { + bool evaluated; + bool resolved; + uint numEvaluatedBallots; + SetUtil.AddressSet nominees; + SetUtil.AddressSet winners; + SetUtil.Bytes32Set ballotPtrs; + mapping(address => uint256) candidateVoteTotals; + } + function load(uint epochIndex) internal pure returns (Data storage election) { + bytes32 s = keccak256(abi.encode("io.synthetix.governance.Election", epochIndex)); + assembly { + election.slot := s + } + } +} + +// @custom:artifact contracts/storage/ElectionSettings.sol:ElectionSettings +library ElectionSettings { + uint64 private constant _MIN_ELECTION_PERIOD_DURATION = 1; + struct Data { + uint8 epochSeatCount; + uint8 minimumActiveMembers; + uint64 epochDuration; + uint64 nominationPeriodDuration; + uint64 votingPeriodDuration; + uint64 maxDateAdjustmentTolerance; + } + function load(uint epochIndex) internal pure returns (Data storage settings) { + bytes32 s = keccak256(abi.encode("io.synthetix.governance.ElectionSettings", epochIndex)); + assembly { + settings.slot := s + } + } +} + +// @custom:artifact contracts/storage/Epoch.sol:Epoch +library Epoch { + enum ElectionPeriod { + Administration, + Nomination, + Vote, + Evaluation + } + struct Data { + uint64 startDate; + uint64 nominationPeriodStartDate; + uint64 votingPeriodStartDate; + uint64 endDate; + } + function load(uint epochIndex) internal pure returns (Data storage epoch) { + bytes32 s = keccak256(abi.encode("io.synthetix.governance.Epoch", epochIndex)); + assembly { + epoch.slot := s + } + } +} + +// @custom:artifact contracts/storage/SnapshotVotePower.sol:SnapshotVotePower +library SnapshotVotePower { + struct Data { + bool enabled; + mapping(uint128 => SnapshotVotePowerEpoch.Data) epochs; + } + function load(address snapshotContract) internal pure returns (Data storage self) { + bytes32 s = keccak256(abi.encode("io.synthetix.governance.SnapshotVotePower", snapshotContract)); + assembly { + self.slot := s + } + } +} + +// @custom:artifact contracts/storage/SnapshotVotePowerEpoch.sol:SnapshotVotePowerEpoch +library SnapshotVotePowerEpoch { + struct Data { + uint128 snapshotId; + mapping(address => uint256) recordedVotingPower; + } +} + +// @custom:artifact contracts/submodules/election/ElectionCredentials.sol:ElectionCredentials +contract ElectionCredentials { + bytes32 internal constant _COUNCIL_NFT_SYSTEM = "councilToken"; +} + +// @custom:artifact contracts/submodules/election/ElectionTally.sol:ElectionTally +contract ElectionTally { + uint16 private constant _DEFAULT_EVALUATION_BATCH_SIZE = 500; +} diff --git a/protocol/governance/tasks/dev.ts b/protocol/governance/tasks/dev.ts new file mode 100644 index 0000000000..d373940274 --- /dev/null +++ b/protocol/governance/tasks/dev.ts @@ -0,0 +1,157 @@ +import path from 'node:path'; +import { cannonBuild } from '@synthetixio/core-modules/test/helpers/cannon'; +import { ccipReceive, CcipRouter } from '@synthetixio/core-modules/test/helpers/ccip'; +import { ethers } from 'ethers'; +import { task } from 'hardhat/config'; +import { HardhatRuntimeEnvironment } from 'hardhat/types'; + +/** + * > DEBUG=hardhat::plugin::anvil::spawn yarn hardhat dev + */ + +const chains = [ + { + name: 'mothership', + networkName: 'sepolia', + cannonfile: 'cannonfile.test.toml', + chainSelector: '16015286601757825753', + }, + { + name: 'satellite1', + networkName: 'optimistic-goerli', + cannonfile: 'cannonfile.satellite.test.toml', + chainSelector: '2664363617261496610', + }, + { + name: 'satellite2', + networkName: 'avalanche-fuji', + cannonfile: 'cannonfile.satellite.test.toml', + chainSelector: '14767482510784806043', + }, +] as const; + +task('dev', 'spins up locally 3 nodes ready for test purposes') + .addOptionalParam( + 'owner', + 'Wallet address to use as signer', + '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266' + ) + .addOptionalParam( + 'port', + 'custom port for chains to run on, increments for satellite chains by 1', + '19000' + ) + .setAction(async ({ owner, port }, hre) => { + const nodes = await Promise.all( + chains.map(({ networkName, cannonfile }, index) => + _spinChain({ + hre, + networkName, + cannonfile, + ownerAddress: owner, + port: Number(port) + index, + }) + ) + ); + + for (const [index, chain] of Object.entries(chains)) { + const node = nodes[index as unknown as number]!; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rpcUrl = (node.provider.passThroughProvider as any).connection.url; + + console.log(); + console.log(`Chain: ${chain.name}`); + console.log(` cannonfile: ${path.basename(node.options.cannonfile)}`); + console.log(` network: ${chain.networkName}`); + console.log(` chainId: ${node.options.chainId}`); + console.log(` rpc: ${rpcUrl}`); + console.log(` CoreProxy: ${node.outputs.contracts?.CoreProxy.address}`); + + // Listen for cross chain message send events, and pass it on to the target network + CcipRouter.connect(node.provider) + .attach(node.outputs.contracts!.CcipRouterMock.address) + .on( + 'CCIPSend', + async (chainSelector: string, message: string, messageId: string, evt: ethers.Event) => { + console.log('CCIPSend', { chainSelector, message, messageId }); + + const targetChainIndex = chains.findIndex((c) => c.chainSelector === chainSelector); + const targetChain = chains[targetChainIndex]!; + const targetNode = nodes[targetChainIndex]!; + + const rx = await node.provider.getTransactionReceipt(evt.transactionHash); + const targetSigner = targetNode.provider.getSigner(owner); + + await ccipReceive({ + rx, + sourceChainSelector: targetChain.chainSelector, + targetSigner, + ccipAddress: targetNode.outputs.contracts!.CcipRouterMock.address, + }); + } + ); + } + + await _keepAlive(); + }); + +async function _spinChain({ + hre, + networkName, + cannonfile, + ownerAddress, + port, +}: { + hre: HardhatRuntimeEnvironment; + networkName: string; + cannonfile: string; + ownerAddress: string; + port: number; +}) { + if (!hre.config.networks[networkName]) { + throw new Error(`Invalid network "${networkName}"`); + } + + const { chainId } = hre.config.networks[networkName]; + + if (typeof chainId !== 'number') { + throw new Error(`Invalid chainId on network ${networkName}`); + } + + console.log(` Building: ${cannonfile} - Network: ${networkName}`); + + return await cannonBuild({ + cannonfile: path.join(hre.config.paths.root, cannonfile), + chainId, + impersonate: ownerAddress, + wipe: true, + getArtifact: async (contractName: string) => + await hre.run('cannon:get-artifact', { name: contractName }), + pkgInfo: require(path.join(hre.config.paths.root, 'package.json')), + projectDirectory: hre.config.paths.root, + port, + }); +} + +function _keepAlive() { + let running = true; + + const stop = () => { + running = false; + }; + + process.on('SIGTERM', stop); + process.on('SIGINT', stop); + process.on('uncaughtException', stop); + + return new Promise((resolve) => { + function run() { + setTimeout(() => { + running ? run() : resolve(); + }, 10); + } + + run(); + }); +} diff --git a/protocol/governance/tasks/test.js b/protocol/governance/tasks/test.js deleted file mode 100644 index e356868dfa..0000000000 --- a/protocol/governance/tasks/test.js +++ /dev/null @@ -1,16 +0,0 @@ -const path = require('path'); -const { subtask } = require('hardhat/config'); -const { glob } = require('hardhat/internal/util/glob'); -const { TASK_TEST_GET_TEST_FILES } = require('hardhat/builtin-tasks/task-names'); - -// Allow glob patterns on testFiles parameter for 'hardhat test' task -// e.g.: yarn hardhat test test/unit/**/*.test.js -subtask(TASK_TEST_GET_TEST_FILES).setAction(async (args, { config }, runSuper) => { - const testFiles = ( - await Promise.all( - args.testFiles.map((testFile) => glob(path.resolve(config.paths.root, testFile))) - ) - ).flat(); - - return await runSuper({ ...args, testFiles }); -}); diff --git a/protocol/governance/test/bootstrap.ts b/protocol/governance/test/bootstrap.ts new file mode 100644 index 0000000000..cb4cb5f41a --- /dev/null +++ b/protocol/governance/test/bootstrap.ts @@ -0,0 +1,54 @@ +import { coreBootstrap } from '@synthetixio/router/dist/utils/tests'; +import hre from 'hardhat'; + +import type { CoreProxy, CouncilToken, SnapshotRecordMock } from './generated/typechain'; + +interface Contracts { + CoreProxy: CoreProxy; + CouncilToken: CouncilToken; + CouncilTokenRouter: CouncilToken; + SnapshotRecordMock: SnapshotRecordMock; +} + +const { getProvider, getSigners, getContract, createSnapshot } = coreBootstrap({ + cannonfile: 'cannonfile.test.toml', +} as { cannonfile: string }); + +function snapshotCheckpoint() { + const restoreSnapshot = createSnapshot(); + after('restore snapshot', restoreSnapshot); +} + +export function bootstrap() { + const contracts: Partial = {}; + const c = contracts as Contracts; + + snapshotCheckpoint(); + + before('load contracts', function () { + Object.assign(contracts, { + CoreProxy: getContract('CoreProxy'), + CouncilToken: getContract('CouncilToken'), + CouncilTokenRouter: getContract('CouncilTokenRouter'), + SnapshotRecordMock: getContract('SnapshotRecordMock'), + }); + }); + + return { + c, + getProvider, + getSigners, + getContract, + snapshotCheckpoint, + + async deployNewProxy(implementation?: string) { + const [owner] = getSigners(); + const factory = await hre.ethers.getContractFactory('Proxy', owner); + const NewProxy = await factory.deploy( + implementation || (await c.CoreProxy.getImplementation()), + await owner.getAddress() + ); + return c.CoreProxy.attach(NewProxy.address); + }, + }; +} diff --git a/protocol/governance/test/constants.ts b/protocol/governance/test/constants.ts new file mode 100644 index 0000000000..f35d66bcec --- /dev/null +++ b/protocol/governance/test/constants.ts @@ -0,0 +1,6 @@ +export enum ElectionPeriod { + Administration = 0, + Nomination = 1, + Vote = 2, + Evaluation = 3, +} diff --git a/protocol/governance/test/contracts/CouncilTokenModule.test.ts b/protocol/governance/test/contracts/CouncilTokenModule.test.ts new file mode 100644 index 0000000000..640549e82c --- /dev/null +++ b/protocol/governance/test/contracts/CouncilTokenModule.test.ts @@ -0,0 +1,44 @@ +import assertRevert from '@synthetixio/core-utils/utils/assertions/assert-revert'; +import { ethers } from 'ethers'; +import { bootstrap } from '../bootstrap'; +import { CouncilTokenModule } from '../generated/typechain/CouncilTokenModule'; + +describe('CouncilTokenModule', function () { + const { c, getSigners, deployNewProxy } = bootstrap(); + + let user1: ethers.Signer; + let user2: ethers.Signer; + let CouncilToken: CouncilTokenModule; + + before('identify signers', async function () { + [, user1, user2] = getSigners(); + }); + + before('deploy new council token router', async function () { + // create a new Proxy with our implementation of the CouncilToken so we can test it isolated + CouncilToken = await deployNewProxy(c.CouncilTokenRouter.address); + }); + + it('can mint council nfts', async function () { + await CouncilToken.mint(await user1.getAddress(), 1); + await CouncilToken.mint(await user2.getAddress(), 2); + }); + + it('can burn council nfts', async function () { + await CouncilToken.burn(1); + await CouncilToken.burn(2); + }); + + it('reverts when trying to transfer', async function () { + await CouncilToken.mint(await user1.getAddress(), 3); + + await assertRevert( + CouncilToken.connect(user1).transferFrom( + await user1.getAddress(), + await user2.getAddress(), + 3 + ), + 'NotImplemented()' + ); + }); +}); diff --git a/protocol/governance/test/contracts/ElectionModule/Initialization.test.ts b/protocol/governance/test/contracts/ElectionModule/Initialization.test.ts new file mode 100644 index 0000000000..f268753bcc --- /dev/null +++ b/protocol/governance/test/contracts/ElectionModule/Initialization.test.ts @@ -0,0 +1,142 @@ +import assert from 'node:assert/strict'; +import assertBn from '@synthetixio/core-utils/utils/assertions/assert-bignumber'; +import assertEvent from '@synthetixio/core-utils/utils/assertions/assert-event'; +import assertRevert from '@synthetixio/core-utils/utils/assertions/assert-revert'; +import { getTime } from '@synthetixio/core-utils/utils/hardhat/rpc'; +import { daysToSeconds } from '@synthetixio/core-utils/utils/misc/dates'; +import { ethers } from 'ethers'; +import { bootstrap } from '../../bootstrap'; +import { ElectionPeriod } from '../../constants'; +import { CoreProxy } from '../../generated/typechain'; + +describe('ElectionModule - Initialization', function () { + const { c, getSigners, getProvider, deployNewProxy } = bootstrap(); + + let owner: ethers.Signer; + let user: ethers.Signer; + + let ElectionModule: CoreProxy; + + async function _initOrUpdateElectionSettings({ + caller = owner, + minimumActiveMembers = 1, + initialNominationPeriodStartDate = 0, + administrationPeriodDuration = 14, + nominationPeriodDuration = 7, + votingPeriodDuration = 7, + } = {}) { + return ElectionModule.connect(caller).initOrUpdateElectionSettings( + [await caller.getAddress()], + minimumActiveMembers, + initialNominationPeriodStartDate, + administrationPeriodDuration, + nominationPeriodDuration, + votingPeriodDuration + ); + } + + before('identify signers', function () { + [owner, user] = getSigners(); + }); + + before('deploy uninitialized module', async function () { + ElectionModule = await deployNewProxy(); + + await ElectionModule.initOrUpgradeNft( + ethers.utils.formatBytes32String('councilToken'), + await c.CouncilToken.name(), + await c.CouncilToken.symbol(), + 'https://synthetix.io', + await c.CouncilToken.getImplementation() + ); + }); + + describe('before initializing the module', function () { + it('shows that the module is not initialized', async function () { + assert.equal(await ElectionModule.isElectionModuleInitialized(), false); + }); + }); + + describe('when initializing the module', function () { + describe('with an account that does not own the instance', function () { + it('reverts', async function () { + await assertRevert(_initOrUpdateElectionSettings({ caller: user }), 'Unauthorized'); + }); + }); + + describe('with the account that owns the instance', function () { + describe('with invalid parameters', function () { + describe('with invalid minimumActiveMembers', function () { + it('reverts using 0', async function () { + await assertRevert( + _initOrUpdateElectionSettings({ minimumActiveMembers: 0 }), + 'InvalidElectionSettings' + ); + }); + }); + }); + + describe('with valid parameters', function () { + let rx: ethers.ContractReceipt; + + let epochStartDate: number; + let nominationPeriodStartDate: number; + let epochEndDate: number; + let votingPeriodStartDate: number; + + before('initialize', async function () { + epochStartDate = await getTime(getProvider()); + + const administrationPeriodDuration = 14; + const nominationPeriodDuration = 7; + const votingPeriodDuration = 7; + + const epochDuration = + administrationPeriodDuration + nominationPeriodDuration + votingPeriodDuration; + + epochEndDate = epochStartDate + daysToSeconds(epochDuration); + nominationPeriodStartDate = + epochEndDate - daysToSeconds(votingPeriodDuration + nominationPeriodDuration); + votingPeriodStartDate = epochEndDate - daysToSeconds(votingPeriodDuration); + + const initialNominationPeriodStartDate = + epochStartDate + daysToSeconds(administrationPeriodDuration); + + const tx = await _initOrUpdateElectionSettings({ + minimumActiveMembers: 1, + initialNominationPeriodStartDate, + administrationPeriodDuration, + nominationPeriodDuration, + votingPeriodDuration, + }); + + rx = await tx.wait(); + }); + + it('shows that the current period is Administration', async function () { + assertBn.equal(await ElectionModule.getCurrentPeriod(), ElectionPeriod.Administration); + }); + + it('shows that the current epoch index is 0', async function () { + assertBn.equal(await ElectionModule.getEpochIndex(), 0); + }); + + it('returns the current schedule', async function () { + const schedule = await ElectionModule.connect(user).getEpochSchedule(); + assertBn.near(schedule.startDate, epochStartDate, 2); + assertBn.near(schedule.endDate, epochEndDate, 2); + assertBn.near(schedule.nominationPeriodStartDate, nominationPeriodStartDate, 2); + assertBn.near(schedule.votingPeriodStartDate, votingPeriodStartDate, 2); + }); + + it('emitted a ElectionModuleInitialized event', async function () { + await assertEvent(rx, 'ElectionModuleInitialized', c.CoreProxy); + }); + + it('emitted a EpochStarted event', async function () { + await assertEvent(rx, 'EpochStarted(0)', c.CoreProxy); + }); + }); + }); + }); +}); diff --git a/protocol/governance/test/contracts/ElectionModule/Schedule.test.ts b/protocol/governance/test/contracts/ElectionModule/Schedule.test.ts new file mode 100644 index 0000000000..dfda34ea64 --- /dev/null +++ b/protocol/governance/test/contracts/ElectionModule/Schedule.test.ts @@ -0,0 +1,226 @@ +import assertBn from '@synthetixio/core-utils/utils/assertions/assert-bignumber'; +import assertEvent from '@synthetixio/core-utils/utils/assertions/assert-event'; +import assertRevert from '@synthetixio/core-utils/utils/assertions/assert-revert'; +import { fastForwardTo, getTime } from '@synthetixio/core-utils/utils/hardhat/rpc'; +import { daysToSeconds } from '@synthetixio/core-utils/utils/misc/dates'; +import { ethers } from 'ethers'; +import { bootstrap } from '../../bootstrap'; +import { ElectionPeriod } from '../../constants'; + +describe('ElectionModule - schedule', () => { + const { c, getSigners, getProvider, snapshotCheckpoint } = bootstrap(); + + let user: ethers.Signer; + let rx: ethers.ContractReceipt; + let newNominationPeriodStartDate: ethers.BigNumberish; + let newVotingPeriodStartDate: ethers.BigNumberish; + let newEpochEndDate: ethers.BigNumberish; + + before('identify signers', async () => { + [, user] = getSigners(); + }); + + // ---------------------------------- + // Evaluation behaviors + // ---------------------------------- + + const itRejectsAdjustments = () => { + describe('when trying to call the tweakEpochSchedule function', function () { + it('reverts', async function () { + await assertRevert(c.CoreProxy.tweakEpochSchedule(0, 0, 0), 'NotCallableInCurrentPeriod'); + }); + }); + }; + + const itAcceptsAdjustments = () => { + describe('when trying to adjust the epoch schedule', function () { + snapshotCheckpoint(); + + before('fast forward', async function () { + const { nominationPeriodStartDate } = await c.CoreProxy.getEpochSchedule(); + await fastForwardTo(Number(nominationPeriodStartDate) - daysToSeconds(1), getProvider()); + }); + + describe('with zero dates', function () { + it('reverts', async function () { + await assertRevert(c.CoreProxy.tweakEpochSchedule(0, 0, 0), 'InvalidEpochConfiguration'); + }); + }); + + describe('with minor changes', function () { + describe('with dates too far from the current dates', function () { + it('reverts', async function () { + const { nominationPeriodStartDate, votingPeriodStartDate, endDate } = + await c.CoreProxy.getEpochSchedule(); + + await assertRevert( + c.CoreProxy.tweakEpochSchedule( + nominationPeriodStartDate.add(daysToSeconds(2)), + votingPeriodStartDate.add(daysToSeconds(2)), + endDate.add(daysToSeconds(8)) + ), + 'InvalidEpochConfiguration' + ); + await assertRevert( + c.CoreProxy.tweakEpochSchedule( + nominationPeriodStartDate.sub(daysToSeconds(8)), + votingPeriodStartDate.add(daysToSeconds(2)), + endDate.add(daysToSeconds(7)) + ), + 'InvalidEpochConfiguration' + ); + await assertRevert( + c.CoreProxy.tweakEpochSchedule( + nominationPeriodStartDate.add(daysToSeconds(2)), + votingPeriodStartDate.sub(daysToSeconds(8)), + endDate.add(daysToSeconds(7)) + ), + 'InvalidEpochConfiguration' + ); + }); + }); + + describe('with dates close to the current dates', function () { + describe('which change the current period type', function () { + it('reverts', async function () { + const { nominationPeriodStartDate, votingPeriodStartDate, endDate } = + await c.CoreProxy.getEpochSchedule(); + + await assertRevert( + c.CoreProxy.tweakEpochSchedule( + nominationPeriodStartDate.sub(daysToSeconds(1)), + votingPeriodStartDate.add(daysToSeconds(0.5)), + endDate.add(daysToSeconds(2)) + ), + 'ChangesCurrentPeriod' + ); + }); + }); + + describe('which dont change the current period type', function () { + before('adjust', async function () { + const { nominationPeriodStartDate, votingPeriodStartDate, endDate } = + await c.CoreProxy.getEpochSchedule(); + + newNominationPeriodStartDate = nominationPeriodStartDate.sub(daysToSeconds(0.5)); + newVotingPeriodStartDate = votingPeriodStartDate.add(daysToSeconds(0.5)); + newEpochEndDate = endDate.add(daysToSeconds(2)); + + const tx = await c.CoreProxy.tweakEpochSchedule( + newNominationPeriodStartDate, + newVotingPeriodStartDate, + newEpochEndDate + ); + rx = await tx.wait(); + }); + + it('emitted an EpochScheduleUpdated event', async function () { + await assertEvent( + rx, + `EpochScheduleUpdated(${newNominationPeriodStartDate}, ${newVotingPeriodStartDate}, ${newEpochEndDate})`, + c.CoreProxy + ); + }); + + it('properly adjusted dates', async function () { + const schedule = await c.CoreProxy.getEpochSchedule(); + assertBn.near(schedule.nominationPeriodStartDate, newNominationPeriodStartDate, 1); + assertBn.near(schedule.votingPeriodStartDate, newVotingPeriodStartDate, 1); + assertBn.near(schedule.endDate, newEpochEndDate, 1); + }); + }); + }); + }); + }); + }; + + // ---------------------------------- + // Administration period + // ---------------------------------- + + describe('when the module is initialized', function () { + it('shows that initial period is Administration', async function () { + assertBn.equal(await c.CoreProxy.getCurrentPeriod(), ElectionPeriod.Administration); + }); + + describe('when an account that does not own the instance attempts to adjust the epoch', function () { + it('reverts', async function () { + await assertRevert(c.CoreProxy.connect(user).tweakEpochSchedule(0, 0, 0), 'Unauthorized'); + }); + }); + + describe('while in the Administration period', function () { + itAcceptsAdjustments(); + }); + + // ---------------------------------- + // Nomination period + // ---------------------------------- + + describe('when entering the nomination period', function () { + before('fast forward', async function () { + const schedule = await c.CoreProxy.getEpochSchedule(); + await fastForwardTo(Number(schedule.nominationPeriodStartDate), getProvider()); + }); + + it('skipped to the target time', async function () { + const schedule = await c.CoreProxy.getEpochSchedule(); + assertBn.near(await getTime(getProvider()), schedule.nominationPeriodStartDate, 1); + }); + + it('shows that the current period is Nomination', async function () { + assertBn.equal(await c.CoreProxy.getCurrentPeriod(), ElectionPeriod.Nomination); + }); + + itRejectsAdjustments(); + }); + + // ---------------------------------- + // Vote period + // ---------------------------------- + + describe('when entering the voting period', function () { + before('ensure nominations', async function () { + await c.CoreProxy.connect(user).nominate(); + }); + + before('fast forward', async function () { + const schedule = await c.CoreProxy.getEpochSchedule(); + await fastForwardTo(Number(schedule.votingPeriodStartDate), getProvider()); + }); + + it('skipped to the target time', async function () { + const schedule = await c.CoreProxy.getEpochSchedule(); + assertBn.near(await getTime(getProvider()), schedule.votingPeriodStartDate, 1); + }); + + it('shows that the current period is Vote', async function () { + assertBn.equal(await c.CoreProxy.getCurrentPeriod(), ElectionPeriod.Vote); + }); + + itRejectsAdjustments(); + }); + + // ---------------------------------- + // Evaluation period + // ---------------------------------- + + describe('when entering the evaluation period', function () { + before('fast forward', async function () { + const schedule = await c.CoreProxy.getEpochSchedule(); + await fastForwardTo(Number(schedule.endDate), getProvider()); + }); + + it('skipped to the target time', async function () { + const schedule = await c.CoreProxy.getEpochSchedule(); + assertBn.near(await getTime(getProvider()), schedule.endDate, 1); + }); + + it('shows that the current period is Evaluation', async function () { + assertBn.equal(await c.CoreProxy.getCurrentPeriod(), ElectionPeriod.Evaluation); + }); + + itRejectsAdjustments(); + }); + }); +}); diff --git a/protocol/governance/test/contracts/ElectionModule/Voting.test.ts b/protocol/governance/test/contracts/ElectionModule/Voting.test.ts new file mode 100644 index 0000000000..3a3f40fd1e --- /dev/null +++ b/protocol/governance/test/contracts/ElectionModule/Voting.test.ts @@ -0,0 +1,93 @@ +import assertRevert from '@synthetixio/core-utils/utils/assertions/assert-revert'; +import { fastForwardTo } from '@synthetixio/core-utils/utils/hardhat/rpc'; +import { ethers } from 'ethers'; +import { bootstrap } from '../../bootstrap'; +import assert from 'assert'; + +describe('ElectionModule - voting', function () { + const { c, getSigners, getProvider } = bootstrap(); + + let user: ethers.Signer; + let otherUser: ethers.Signer; + + before('identify signers', async function () { + [, user, otherUser] = getSigners(); + }); + + before('create voting power for user', async function () { + await c.CoreProxy.Ballot_set_votingPower( + await c.CoreProxy.Council_get_currentElectionId(), + await user.getAddress(), + 13370, + 100 + ); + }); + + describe('#cast', function () { + it('reverts if not in the voting period', async function () { + await assertRevert( + c.CoreProxy.connect(user).cast([await user.getAddress()], [1]), + 'NotCallableInCurrentPeriod()', + c.CoreProxy + ); + }); + + describe('when in the voting period', function () { + before('fast forward', async function () { + const schedule = await c.CoreProxy.getEpochSchedule(); + await fastForwardTo(Number(schedule.votingPeriodStartDate), getProvider()); + }); + + it('reverts if ballot has too many candidates', async function () { + const candidates = [ + ethers.Wallet.createRandom().address, + ethers.Wallet.createRandom().address, + ]; + + await assertRevert( + c.CoreProxy.connect(user).cast(candidates, [1, 1]), + 'InvalidParameter("candidates", "too many candidates")', + c.CoreProxy + ); + }); + + it('reverts if voting power does not exist', async function () { + const sender = await otherUser.getAddress(); + await assertRevert( + c.CoreProxy.connect(otherUser).cast([sender], [0]), + `NoVotingPower("${sender}", "${await c.CoreProxy.getEpochIndex()}")`, + c.CoreProxy + ); + }); + + describe('when the user is nominated', async function () { + before('nominate user', async function () { + await c.CoreProxy.connect(user).nominate(); + }); + + it('reverts if ballot voting power does not match', async function () { + await assertRevert( + c.CoreProxy.connect(user).cast([await user.getAddress()], [1]), + 'InvalidBallot()', + c.CoreProxy + ); + }); + + it('succeeds if ballot voting power matches', async function () { + await c.CoreProxy.connect(user).cast([await user.getAddress()], [100]); + }); + + it('succeeds if user withdraws his own vote', async () => { + await c.CoreProxy.connect(user).withdrawVote([await user.getAddress()]); + assert.equal( + await c.CoreProxy.connect(user).hasVoted( + await user.getAddress(), + (await user.provider!.getNetwork()).chainId + ), + false + ); + }); + }); + }); + }); +}); diff --git a/protocol/governance/test/contracts/ElectionSchedule.test.ts b/protocol/governance/test/contracts/ElectionSchedule.test.ts new file mode 100644 index 0000000000..8d72b4a043 --- /dev/null +++ b/protocol/governance/test/contracts/ElectionSchedule.test.ts @@ -0,0 +1,207 @@ +import assertBn from '@synthetixio/core-utils/utils/assertions/assert-bignumber'; +import assertRevert from '@synthetixio/core-utils/utils/assertions/assert-revert'; +import { fastForwardTo, getTime } from '@synthetixio/core-utils/utils/hardhat/rpc'; +import { daysToSeconds } from '@synthetixio/core-utils/utils/misc/dates'; +import { ethers } from 'ethers'; +import { bootstrap } from '../bootstrap'; +import { ElectionPeriod } from '../constants'; + +interface ScheduleConfig { + nominationPeriodStartDate: ethers.BigNumber; + votingPeriodStartDate: ethers.BigNumber; + endDate: ethers.BigNumber; +} + +describe('ElectionSchedule', function () { + const { c, getSigners, getProvider, snapshotCheckpoint } = bootstrap(); + + let user: ethers.Signer; + + before('identify signers', function () { + [, user] = getSigners(); + }); + + describe('#getEpochSchedule', function () { + it('shows the current schedule', async function () { + const now = await getTime(getProvider()); + const schedule = await c.CoreProxy.connect(user).getEpochSchedule(); + assertBn.gt(schedule.startDate, 0); + assertBn.gt(schedule.endDate, now); + assertBn.gt(schedule.nominationPeriodStartDate, now); + assertBn.gt(schedule.votingPeriodStartDate, now); + }); + }); + + describe('#tweakEpochSchedule', function () { + snapshotCheckpoint(); + + async function _tweakEpochSchedule({ + nominationPeriodStartDate, + votingPeriodStartDate, + endDate, + }: Partial = {}) { + const schedule = await c.CoreProxy.getEpochSchedule(); + const tx = await c.CoreProxy.tweakEpochSchedule( + nominationPeriodStartDate || schedule.nominationPeriodStartDate, + votingPeriodStartDate || schedule.votingPeriodStartDate, + endDate || schedule.endDate + ); + await tx.wait(); + return tx; + } + + it('shows that the current period is Administration', async function () { + assertBn.equal(await c.CoreProxy.getCurrentPeriod(), ElectionPeriod.Administration); + }); + + describe('with an account that does not own the instance', function () { + it('reverts', async function () { + await assertRevert(c.CoreProxy.connect(user).tweakEpochSchedule(0, 0, 0), 'Unauthorized'); + }); + }); + + describe('when trying to modify outside of "maxDateAdjustmentTolerance" settings', function () { + let tolerance: ethers.BigNumber; + let schedule: ScheduleConfig; + + before('load current configuration', async function () { + const settings = await c.CoreProxy.getElectionSettings(); + tolerance = settings.maxDateAdjustmentTolerance; + schedule = await c.CoreProxy.getEpochSchedule(); + }); + + const dateNames = ['nominationPeriodStartDate', 'votingPeriodStartDate', 'endDate'] as const; + + for (const dateName of dateNames) { + it(`reverts when new "${dateName}" is less than "maxDateAdjustmentTolerance"`, async function () { + await assertRevert( + _tweakEpochSchedule({ + [dateName]: schedule[dateName].sub(tolerance).sub(1), + }), + 'InvalidEpochConfiguration' + ); + }); + + it(`reverts when new "${dateName}" is over "maxDateAdjustmentTolerance"`, async function () { + await assertRevert( + _tweakEpochSchedule({ + [dateName]: schedule[dateName].add(tolerance).add(1), + }), + 'InvalidEpochConfiguration' + ); + }); + } + }); + + describe('when trying to bypass "maxDateAdjustmentTolerance" by calling it several times', function () { + describe('when tweaking "epochEndDate"', function () { + snapshotCheckpoint(); + + it('reverts', async function () { + const schedule = await c.CoreProxy.getEpochSchedule(); + const settings = await c.CoreProxy.getElectionSettings(); + const tolerance = settings.maxDateAdjustmentTolerance; + + // Bring the date to the limit + await _tweakEpochSchedule({ + endDate: schedule.endDate.add(tolerance), + }); + + await assertRevert( + _tweakEpochSchedule({ + endDate: schedule.endDate.add(tolerance).add(tolerance), + }), + 'InvalidEpochConfiguration' + ); + }); + }); + }); + + describe('when a tweak modifies the current period', function () { + let schedule: ScheduleConfig; + + snapshotCheckpoint(); + + before('load current configuration', async function () { + schedule = await c.CoreProxy.getEpochSchedule(); + }); + + before('fast forward', async function () { + await fastForwardTo( + schedule.nominationPeriodStartDate.sub(daysToSeconds(1)).toNumber(), + getProvider() + ); + }); + + it('reverts', async function () { + const nominationPeriodStartDate = schedule.nominationPeriodStartDate.sub(daysToSeconds(2)); + await assertRevert( + _tweakEpochSchedule({ nominationPeriodStartDate }), + 'ChangesCurrentPeriod' + ); + }); + }); + + describe('when correctly tweaking inside "maxDateAdjustmentTolerance"', function () { + snapshotCheckpoint(); + + it('correctly tweaks to new schedule', async function () { + const original = await c.CoreProxy.getEpochSchedule(); + + const newSchedule = { + nominationPeriodStartDate: original.nominationPeriodStartDate.add(daysToSeconds(2)), + votingPeriodStartDate: original.votingPeriodStartDate.add(daysToSeconds(2)), + endDate: original.endDate.add(daysToSeconds(2)), + }; + + await _tweakEpochSchedule(newSchedule); + + const result = await c.CoreProxy.getEpochSchedule(); + assertBn.equal(result.nominationPeriodStartDate, newSchedule.nominationPeriodStartDate); + assertBn.equal(result.votingPeriodStartDate, newSchedule.votingPeriodStartDate); + assertBn.equal(result.endDate, newSchedule.endDate); + }); + }); + + // TODO: fix weird error, running this tests breaks another ones + describe.skip('when calling it outside of Administration period', function () { + let schedule: ScheduleConfig; + + snapshotCheckpoint(); + + before('load current configuration', async function () { + schedule = await c.CoreProxy.getEpochSchedule(); + }); + + describe('when calling during Nomination period', function () { + before('fast forward', async function () { + await fastForwardTo(schedule.nominationPeriodStartDate.toNumber(), getProvider()); + }); + + it('reverts', async function () { + await assertRevert(_tweakEpochSchedule(), 'NotCallableInCurrentPeriod'); + }); + + describe('when calling during Vote period', function () { + before('fast forward', async function () { + await fastForwardTo(schedule.votingPeriodStartDate.toNumber(), getProvider()); + }); + + it('reverts', async function () { + await assertRevert(_tweakEpochSchedule(), 'NotCallableInCurrentPeriod'); + }); + + describe('when calling during Evaluation period', function () { + before('fast forward', async function () { + await fastForwardTo(schedule.endDate.toNumber(), getProvider()); + }); + + it('reverts', async function () { + await assertRevert(_tweakEpochSchedule(), 'NotCallableInCurrentPeriod'); + }); + }); + }); + }); + }); + }); +}); diff --git a/protocol/governance/test/contracts/ElectionSettings.test.ts b/protocol/governance/test/contracts/ElectionSettings.test.ts new file mode 100644 index 0000000000..92c5edf0b0 --- /dev/null +++ b/protocol/governance/test/contracts/ElectionSettings.test.ts @@ -0,0 +1,128 @@ +import assertBn from '@synthetixio/core-utils/utils/assertions/assert-bignumber'; +import assertRevert from '@synthetixio/core-utils/utils/assertions/assert-revert'; +import { daysToSeconds } from '@synthetixio/core-utils/utils/misc/dates'; +import { ethers } from 'ethers'; +import { bootstrap } from '../bootstrap'; + +interface ElectionSettings { + epochSeatCount: ethers.BigNumberish; + minimumActiveMembers: ethers.BigNumberish; + epochDuration: ethers.BigNumberish; + nominationPeriodDuration: ethers.BigNumberish; + votingPeriodDuration: ethers.BigNumberish; + maxDateAdjustmentTolerance: ethers.BigNumberish; +} + +describe('ElectionSettings', function () { + const { c, getSigners } = bootstrap(); + + let owner: ethers.Signer; + let user: ethers.Signer; + + before('identify signers', function () { + [owner, user] = getSigners(); + }); + + describe('#getElectionSettings', function () { + it('returns current election settings', async function () { + const settings = await c.CoreProxy.connect(user).getElectionSettings(); + assertBn.gt(settings.epochSeatCount, 0); + assertBn.gt(settings.minimumActiveMembers, 0); + assertBn.gt(settings.epochDuration, 0); + assertBn.gt(settings.nominationPeriodDuration, 0); + assertBn.gt(settings.votingPeriodDuration, 0); + assertBn.gt(settings.maxDateAdjustmentTolerance, 0); + }); + }); + + describe('#setNextElectionSettings', function () { + async function _setNextElectionSettings( + settings: Partial = {}, + caller = owner + ) { + const tx = await c.CoreProxy.connect(caller).setNextElectionSettings( + settings.epochSeatCount ?? 2, + settings.minimumActiveMembers ?? 1, + settings.epochDuration ?? 90, + settings.nominationPeriodDuration ?? 2, + settings.votingPeriodDuration ?? 2, + settings.maxDateAdjustmentTolerance ?? 2 + ); + await tx.wait(); + return tx; + } + + describe('with an account that does not own the instance', function () { + it('reverts', async function () { + await assertRevert(_setNextElectionSettings({}, user), 'Unauthorized'); + }); + }); + + describe('with invalid settings', function () { + const testCases = [ + { epochSeatCount: 0 }, + { minimumActiveMembers: 0 }, + { epochDuration: 0 }, + { nominationPeriodDuration: 0 }, + { votingPeriodDuration: 0 }, + { epochSeatCount: 1, minimumActiveMembers: 2 }, + { + epochDuration: daysToSeconds(4), + nominationPeriodDuration: daysToSeconds(3), + votingPeriodDuration: daysToSeconds(3), + }, + { nominationPeriodDuration: daysToSeconds(1) - 1 }, + { votingPeriodDuration: daysToSeconds(1) - 1 }, + { + nominationPeriodDuration: daysToSeconds(2), + maxDateAdjustmentTolerance: daysToSeconds(2), + }, + { + votingPeriodDuration: daysToSeconds(2), + maxDateAdjustmentTolerance: daysToSeconds(2), + }, + ]; + + for (const settings of testCases) { + it(`reverts when using ${JSON.stringify(settings)}`, async function () { + await assertRevert(_setNextElectionSettings(settings), 'InvalidElectionSettings'); + }); + } + }); + + describe('with valid settings', function () { + it('sets new settings for next epoch', async function () { + const newSettings = { + epochSeatCount: 5, + minimumActiveMembers: 2, + epochDuration: 30, + nominationPeriodDuration: 7, + votingPeriodDuration: 7, + maxDateAdjustmentTolerance: 3, + } as ElectionSettings; + + await _setNextElectionSettings(newSettings); + + const result = await c.CoreProxy.getNextElectionSettings(); + + assertBn.equal(result.epochSeatCount, newSettings.epochSeatCount); + assertBn.equal(result.minimumActiveMembers, newSettings.minimumActiveMembers); + assertBn.equal(result.epochDuration, daysToSeconds(newSettings.epochDuration as number)); + assertBn.equal( + result.nominationPeriodDuration, + daysToSeconds(newSettings.nominationPeriodDuration as number) + ); + assertBn.equal( + result.votingPeriodDuration, + daysToSeconds(newSettings.votingPeriodDuration as number) + ); + assertBn.equal( + result.maxDateAdjustmentTolerance, + daysToSeconds(newSettings.maxDateAdjustmentTolerance as number) + ); + }); + }); + + // TODO: test callable only during Administration + }); +}); diff --git a/protocol/governance/test/contracts/SnapshotVotePowerModule.test.ts b/protocol/governance/test/contracts/SnapshotVotePowerModule.test.ts new file mode 100644 index 0000000000..b01a06a018 --- /dev/null +++ b/protocol/governance/test/contracts/SnapshotVotePowerModule.test.ts @@ -0,0 +1,201 @@ +import assertBn from '@synthetixio/core-utils/utils/assertions/assert-bignumber'; +import assertRevert from '@synthetixio/core-utils/utils/assertions/assert-revert'; +import { fastForwardTo } from '@synthetixio/core-utils/utils/hardhat/rpc'; +import { snapshotCheckpoint } from '@synthetixio/core-utils/utils/mocha/snapshot'; +import assert from 'assert/strict'; +import { ethers } from 'ethers'; +import { bootstrap } from '../bootstrap'; + +describe('SnapshotVotePowerModule', function () { + const { c, getSigners, getProvider } = bootstrap(); + + let owner: ethers.Signer; + let user: ethers.Signer; + + before('identify signers', function () { + [owner, user] = getSigners(); + }); + + const restore = snapshotCheckpoint(getProvider); + + describe('#setSnapshotContract', function () { + before(restore); + + it('should revert when not owner', async function () { + await assertRevert( + c.CoreProxy.connect(user).setSnapshotContract(c.SnapshotRecordMock.address, true), + `Unauthorized("${await user.getAddress()}"`, + c.CoreProxy + ); + }); + + it('should not be valid until initialized', async function () { + assert.equal( + await c.CoreProxy.SnapshotVotePower_get_enabled(c.SnapshotRecordMock.address), + false + ); + }); + + it('should set snapshot contract', async function () { + await c.CoreProxy.setSnapshotContract(c.SnapshotRecordMock.address, true); + assert.equal( + await c.CoreProxy.SnapshotVotePower_get_enabled(c.SnapshotRecordMock.address), + true + ); + }); + + it('should unset snapshot contract', async function () { + await c.CoreProxy.setSnapshotContract(c.SnapshotRecordMock.address, false); + assert.equal( + await c.CoreProxy.SnapshotVotePower_get_enabled(c.SnapshotRecordMock.address), + false + ); + }); + }); + + describe('#takeVotePowerSnapshot', function () { + before(restore); + + const disabledSnapshotContract = ethers.Wallet.createRandom().address; + before('setup snapshot contracts', async function () { + // setup main snapshot contract + await c.CoreProxy.setSnapshotContract(c.SnapshotRecordMock.address, true); + + // setup and disable an snapshot contract + await c.CoreProxy.setSnapshotContract(disabledSnapshotContract, true); + await c.CoreProxy.setSnapshotContract(disabledSnapshotContract, false); + }); + + it('should revert when not correct epoch phase', async function () { + await assertRevert( + c.CoreProxy.takeVotePowerSnapshot(c.SnapshotRecordMock.address), + 'NotCallableInCurrentPeriod', + c.CoreProxy + ); + }); + + describe('advance time to nomination phase', function () { + before('advance time', async function () { + const settings = await c.CoreProxy.getEpochSchedule(); + await fastForwardTo(settings.nominationPeriodStartDate.toNumber(), getProvider()); + }); + + it('should revert when using invalid snapshot contract', async function () { + await assertRevert( + c.CoreProxy.takeVotePowerSnapshot(ethers.Wallet.createRandom().address), + 'InvalidSnapshotContract', + c.CoreProxy + ); + }); + + it('should revert when using disabled snapshot contract', async function () { + await assertRevert( + c.CoreProxy.takeVotePowerSnapshot(disabledSnapshotContract), + 'InvalidSnapshotContract', + c.CoreProxy + ); + }); + + it('should take vote power snapshot', async function () { + assertBn.equal( + await c.CoreProxy.getVotePowerSnapshotId( + c.SnapshotRecordMock.address, + await c.CoreProxy.Council_get_currentElectionId() + ), + 0 + ); + await c.CoreProxy.takeVotePowerSnapshot(c.SnapshotRecordMock.address); + assertBn.gt( + await c.CoreProxy.getVotePowerSnapshotId( + c.SnapshotRecordMock.address, + await c.CoreProxy.Council_get_currentElectionId() + ), + 0 + ); + }); + + it('should fail with snapshot already taken if we repeat', async function () { + await assertRevert( + c.CoreProxy.takeVotePowerSnapshot(c.SnapshotRecordMock.address), + 'SnapshotAlreadyTaken', + c.CoreProxy + ); + }); + }); + }); + + describe('#prepareBallotWithSnapshot', function () { + before(restore); + + const disabledSnapshotContract = ethers.Wallet.createRandom().address; + + before('setup disabled snapshot contract', async function () { + // setup and disable an snapshot contract + await c.CoreProxy.setSnapshotContract(disabledSnapshotContract, true); + await c.CoreProxy.setSnapshotContract(disabledSnapshotContract, false); + }); + + before('set snapshot contract', async function () { + await c.CoreProxy.setSnapshotContract(c.SnapshotRecordMock.address, true); + const settings = await c.CoreProxy.getEpochSchedule(); + await fastForwardTo(settings.nominationPeriodStartDate.toNumber(), getProvider()); + await c.CoreProxy.takeVotePowerSnapshot(c.SnapshotRecordMock.address); + + const snapshotId = await c.CoreProxy.getVotePowerSnapshotId( + c.SnapshotRecordMock.address, + await c.CoreProxy.Council_get_currentElectionId() + ); + + await c.SnapshotRecordMock.setBalanceOfOnPeriod(await user.getAddress(), 100, snapshotId); + }); + + it('cannot prepare ballot before voting starts', async function () { + await assertRevert( + c.CoreProxy.connect(owner).prepareBallotWithSnapshot( + c.SnapshotRecordMock.address, + await user.getAddress() + ), + 'NotCallableInCurrentPeriod', + c.CoreProxy + ); + }); + + describe('advance to voting period', function () { + before('advance time', async function () { + const settings = await c.CoreProxy.getEpochSchedule(); + await fastForwardTo(settings.votingPeriodStartDate.toNumber(), getProvider()); + }); + + it('should revert when using disabled snapshot contract', async function () { + await assertRevert( + c.CoreProxy.prepareBallotWithSnapshot(disabledSnapshotContract, await user.getAddress()), + 'InvalidSnapshotContract', + c.CoreProxy + ); + }); + + it('should create an empty ballot with voting power for specified user', async function () { + const foundVotingPower = await c.CoreProxy.connect( + owner + ).callStatic.prepareBallotWithSnapshot( + c.SnapshotRecordMock.address, + await user.getAddress() + ); + await c.CoreProxy.connect(owner).prepareBallotWithSnapshot( + c.SnapshotRecordMock.address, + await user.getAddress() + ); + + assertBn.equal(foundVotingPower, 100); + + const ballotVotingPower = await c.CoreProxy.Ballot_get_votingPower( + await c.CoreProxy.Council_get_currentElectionId(), + await user.getAddress(), + 13370 // precinct is current chain id + ); + + assertBn.equal(ballotVotingPower, 100); + }); + }); + }); +}); diff --git a/protocol/governance/test/contracts/modules/ElectionModule/Elections.test.js b/protocol/governance/test/contracts/modules/ElectionModule/Elections.test.js deleted file mode 100644 index f9a70e889b..0000000000 --- a/protocol/governance/test/contracts/modules/ElectionModule/Elections.test.js +++ /dev/null @@ -1,712 +0,0 @@ -const { ethers } = hre; -const assert = require('assert/strict'); -const assertBn = require('@synthetixio/core-utils/utils/assertions/assert-bignumber'); - -const assertRevert = require('@synthetixio/core-utils/utils/assertions/assert-revert'); -const { bootstrap } = require('@synthetixio/router/utils/tests'); -const initializer = require('@synthetixio/core-modules/test/helpers/initializer'); -const { - getTime, - fastForwardTo, - takeSnapshot, - restoreSnapshot, -} = require('@synthetixio/core-utils/utils/hardhat/rpc'); - -const { daysToSeconds } = require('@synthetixio/core-utils/utils/misc/dates'); -const { - ElectionPeriod, -} = require('@synthetixio/core-modules/test/contracts/modules/ElectionModule/helpers/election-helper'); -const { - simulateDebtShareData, - simulateCrossChainDebtShareData, - expectedDebtShare, - expectedVotePower, - expectedCrossChainDebtShare, - getCrossChainMerkleTree, -} = require('./helpers/debt-share-helper'); -const { findEvent } = require('@synthetixio/core-utils/utils/ethers/events'); - -describe('SynthetixElectionModule - general elections', function () { - const { proxyAddress } = bootstrap(initializer); - - let ElectionModule, DebtShare, CouncilToken; - - let owner; - let user1, user2, user3, user4, user5, user6, user7, user8, user9; - - let receipt; - - let merkleTree; - - let snapshotId; - - const epochData = [ - { - index: 0, - debtShareSnapshotId: 42, - blockNumber: 21000000, - winners: () => [user4.address, user5.address], - }, - { - index: 1, - debtShareSnapshotId: 1337, - blockNumber: 23100007, - winners: () => [user4.address, user6.address], - }, - { - index: 2, - debtShareSnapshotId: 2192, - blockNumber: 30043001, - winners: () => [user6.address, user5.address], - }, - ]; - - before('identify signers', async () => { - [owner, user1, user2, user3, user4, user5, user6, user7, user8, user9] = - await ethers.getSigners(); - }); - - before('identify modules', async () => { - ElectionModule = await ethers.getContractAt( - 'contracts/modules/ElectionModule.sol:ElectionModule', - proxyAddress() - ); - }); - - before('deploy debt shares mock', async function () { - const factory = await ethers.getContractFactory('DebtShareMock'); - DebtShare = await factory.deploy(); - }); - - describe('when the election module is initialized', function () { - before('initialize', async function () { - const now = await getTime(ethers.provider); - const epochEndDate = now + daysToSeconds(90); - const votingPeriodStartDate = epochEndDate - daysToSeconds(7); - const nominationPeriodStartDate = votingPeriodStartDate - daysToSeconds(7); - - await ElectionModule[ - 'initializeElectionModule(string,string,address[],uint8,uint64,uint64,uint64,address)' - ]( - 'Spartan Council Token', - 'SCT', - [owner.address], - 1, - nominationPeriodStartDate, - votingPeriodStartDate, - epochEndDate, - DebtShare.address - ); - }); - - before('set next epoch seat count to 2', async function () { - (await ElectionModule.setNextEpochSeatCount(2)).wait(); - }); - - before('identify the council token', async function () { - CouncilToken = await ethers.getContractAt( - 'CouncilToken', - await ElectionModule.getCouncilToken() - ); - }); - - it('shows the expected NFT owners', async function () { - assertBn.equal(await CouncilToken.balanceOf(owner.address), 1); - assertBn.equal(await CouncilToken.balanceOf(user1.address), 0); - assertBn.equal(await CouncilToken.balanceOf(user2.address), 0); - assertBn.equal(await CouncilToken.balanceOf(user3.address), 0); - }); - - it('shows that the election module is initialized', async function () { - assert.equal(await ElectionModule.isElectionModuleInitialized(), true); - }); - - it('shows that the DebtShare contract is connected', async function () { - assert.equal(await ElectionModule.getDebtShareContract(), DebtShare.address); - }); - - describe('when re setting the debt share contract', function () { - describe('with the same address', function () { - it('reverts', async function () { - await assertRevert(ElectionModule.setDebtShareContract(DebtShare.address), 'NoChange'); - }); - }); - - describe('with an invalid address', function () { - it('reverts', async function () { - await assertRevert( - ElectionModule.setDebtShareContract('0x0000000000000000000000000000000000000000'), - 'ZeroAddress' - ); - }); - }); - - describe('with an EOA', function () { - it('reverts', async function () { - await assertRevert(ElectionModule.setDebtShareContract(owner.address), 'NotAContract'); - }); - }); - - describe('with a different address', function () { - before('deploy debt shares mock', async function () { - const factory = await ethers.getContractFactory('DebtShareMock'); - DebtShare = await factory.deploy(); - }); - - before('set the new debt share contract', async function () { - const tx = await ElectionModule.setDebtShareContract(DebtShare.address); - receipt = await tx.wait(); - }); - - it('emitted a DebtShareContractSet event', async function () { - const event = findEvent({ receipt, eventName: 'DebtShareContractSet' }); - - assert.ok(event); - assertBn.equal(event.args.contractAddress, DebtShare.address); - }); - - it('shows that the DebtShare contract is connected', async function () { - assert.equal(await ElectionModule.getDebtShareContract(), DebtShare.address); - }); - }); - }); - - epochData.forEach(function (epoch) { - describe(`epoch ${epoch.index} with debt share snapshot ${epoch.debtShareSnapshotId}`, function () { - it(`shows that the current epoch index is ${epoch.index}`, async function () { - assertBn.equal(await ElectionModule.getEpochIndex(), epoch.index); - }); - - it('shows that the current period is Administration', async function () { - assertBn.equal(await ElectionModule.getCurrentPeriod(), ElectionPeriod.Administration); - }); - - describe('before a debt share snapshot is set', function () { - describe('when trying to retrieve the current debt share snapshot id', function () { - it('reverts', async function () { - await assertRevert( - ElectionModule.getDebtShareSnapshotId(), - 'DebtShareSnapshotIdNotSet' - ); - }); - }); - - describe('when trying to retrieve the current debt share of a user', function () { - it('returns zero', async function () { - assertBn.equal(await ElectionModule.getDebtShare(user1.address), 0); - }); - }); - }); - - describe('before a merkle root is set', function () { - describe('when trying to retrieve the current cross chain merkle root', function () { - it('reverts', async function () { - await assertRevert( - ElectionModule.getCrossChainDebtShareMerkleRoot(), - 'MerkleRootNotSet' - ); - }); - }); - - describe('when trying to retrieve the current cross chain merkle root block number', function () { - it('reverts', async function () { - await assertRevert( - ElectionModule.getCrossChainDebtShareMerkleRootBlockNumber(), - 'MerkleRootNotSet' - ); - }); - }); - - describe('when trying to retrieve the current cross chain debt share of a user', function () { - it('returns zero', async function () { - assertBn.equal(await ElectionModule.getDeclaredCrossChainDebtShare(user1.address), 0); - }); - }); - }); - - describe('before the nomination period begins', function () { - describe('when trying to set the debt share id', function () { - it('reverts', async function () { - await assertRevert( - ElectionModule.setDebtShareSnapshotId(0), - 'NotCallableInCurrentPeriod' - ); - }); - }); - - describe('when trying to set the cross chain debt share merkle root', function () { - it('reverts', async function () { - await assertRevert( - ElectionModule.setCrossChainDebtShareMerkleRoot( - '0x000000000000000000000000000000000000000000000000000000000000beef', - 1337 - ), - 'NotCallableInCurrentPeriod' - ); - }); - }); - }); - - describe('when advancing to the nominations period', function () { - before('fast forward', async function () { - await fastForwardTo( - await ElectionModule.getNominationPeriodStartDate(), - ethers.provider - ); - }); - - describe('when the current epochs debt share snapshot id is set', function () { - before('simulate debt share data', async function () { - await simulateDebtShareData(DebtShare, [user1, user2, user3, user4, user5]); - }); - - before('set snapshot id', async function () { - const tx = await ElectionModule.setDebtShareSnapshotId(epoch.debtShareSnapshotId); - receipt = await tx.wait(); - }); - - it('emitted a DebtShareSnapshotIdSet event', async function () { - const event = findEvent({ receipt, eventName: 'DebtShareSnapshotIdSet' }); - - assert.ok(event); - assertBn.equal(event.args.snapshotId, epoch.debtShareSnapshotId); - }); - - it('shows that the snapshot id is set', async function () { - assertBn.equal( - await ElectionModule.getDebtShareSnapshotId(), - epoch.debtShareSnapshotId - ); - }); - - it('shows that users have the expected debt shares', async function () { - assert.deepEqual( - await ElectionModule.getDebtShare(user1.address), - expectedDebtShare(user1.address, epoch.debtShareSnapshotId) - ); - assert.deepEqual( - await ElectionModule.getDebtShare(user2.address), - expectedDebtShare(user2.address, epoch.debtShareSnapshotId) - ); - assert.deepEqual( - await ElectionModule.getDebtShare(user3.address), - expectedDebtShare(user3.address, epoch.debtShareSnapshotId) - ); - assert.deepEqual( - await ElectionModule.getDebtShare(user4.address), - expectedDebtShare(user4.address, epoch.debtShareSnapshotId) - ); - assert.deepEqual( - await ElectionModule.getDebtShare(user5.address), - expectedDebtShare(user5.address, epoch.debtShareSnapshotId) - ); - }); - - describe('when cross chain debt share data is collected', function () { - before('simulate cross chain debt share data', async function () { - await simulateCrossChainDebtShareData([user1, user2, user3]); - - merkleTree = getCrossChainMerkleTree(epoch.debtShareSnapshotId); - }); - - describe('when a user attempts to declare cross chain debt shares and the merkle root is not set', function () { - before('take snapshot', async function () { - snapshotId = await takeSnapshot(ethers.provider); - }); - - after('restore snapshot', async function () { - await restoreSnapshot(snapshotId, ethers.provider); - }); - - before('fast forward', async function () { - await fastForwardTo( - await ElectionModule.getVotingPeriodStartDate(), - ethers.provider - ); - }); - - it('reverts', async function () { - merkleTree = getCrossChainMerkleTree(epoch.debtShareSnapshotId); - - await assertRevert( - ElectionModule.declareCrossChainDebtShare( - user1.address, - expectedCrossChainDebtShare(user1.address, epoch.debtShareSnapshotId), - merkleTree.claims[user1.address].proof - ), - 'MerkleRootNotSet' - ); - }); - }); - - describe('when the current epochs cross chain debt share merkle root is set', function () { - before('set the merkle root', async function () { - const tx = await ElectionModule.setCrossChainDebtShareMerkleRoot( - merkleTree.merkleRoot, - epoch.blockNumber - ); - receipt = await tx.wait(); - }); - - before('nominate', async function () { - (await ElectionModule.connect(user4).nominate()).wait(); - (await ElectionModule.connect(user5).nominate()).wait(); - (await ElectionModule.connect(user6).nominate()).wait(); - (await ElectionModule.connect(user7).nominate()).wait(); - (await ElectionModule.connect(user8).nominate()).wait(); - (await ElectionModule.connect(user9).nominate()).wait(); - }); - - it('emitted a CrossChainDebtShareMerkleRootSet event', async function () { - const event = findEvent({ - receipt, - eventName: 'CrossChainDebtShareMerkleRootSet', - }); - - assert.ok(event); - assertBn.equal(event.args.merkleRoot, merkleTree.merkleRoot); - assertBn.equal(event.args.blocknumber, epoch.blockNumber); - }); - - it('shows that the merkle root is set', async function () { - assert.equal( - await ElectionModule.getCrossChainDebtShareMerkleRoot(), - merkleTree.merkleRoot - ); - }); - - it('shows that the merkle root block number is set', async function () { - assertBn.equal( - await ElectionModule.getCrossChainDebtShareMerkleRootBlockNumber(), - epoch.blockNumber - ); - }); - - describe('when users declare their cross chain debt shares in the wrong period', function () { - it('reverts', async function () { - await assertRevert( - ElectionModule.declareCrossChainDebtShare( - user1.address, - expectedCrossChainDebtShare(user1.address, epoch.debtShareSnapshotId), - merkleTree.claims[user1.address].proof - ), - 'NotCallableInCurrentPeriod' - ); - }); - }); - - describe('when advancing to the voting period', function () { - before('fast forward', async function () { - await fastForwardTo( - await ElectionModule.getVotingPeriodStartDate(), - ethers.provider - ); - }); - - it('shows that the current period is Voting', async function () { - assertBn.equal(await ElectionModule.getCurrentPeriod(), ElectionPeriod.Vote); - }); - - describe('when users declare their cross chain debt shares incorrectly', function () { - describe('when a user declares a wrong amount', function () { - it('reverts', async function () { - const { proof } = merkleTree.claims[user2.address]; - - await assertRevert( - ElectionModule.declareCrossChainDebtShare( - user2.address, - ethers.utils.parseEther('10000000'), - proof - ), - 'InvalidMerkleProof' - ); - }); - }); - - describe('when a user with no entry in the tree declares an amount', function () { - it('reverts', async function () { - const { proof } = merkleTree.claims[user2.address]; - - await assertRevert( - ElectionModule.declareCrossChainDebtShare( - user4.address, - ethers.utils.parseEther('1000'), - proof - ), - 'InvalidMerkleProof' - ); - }); - }); - - describe('when a user uses the wrong tree to declare', function () { - it('reverts', async function () { - const anotherTree = getCrossChainMerkleTree(666); - const { amount, proof } = anotherTree.claims[user2.address]; - - await assertRevert( - ElectionModule.declareCrossChainDebtShare(user2.address, amount, proof), - 'InvalidMerkleProof' - ); - }); - }); - }); - - describe('when users declare their cross chain debt shares correctly', function () { - async function declare(user) { - const { amount, proof } = merkleTree.claims[user.address]; - - const tx = await ElectionModule.declareCrossChainDebtShare( - user.address, - amount, - proof - ); - receipt = await tx.wait(); - } - - async function declareAndCast(user, candidates) { - const { amount, proof } = merkleTree.claims[user.address]; - - const tx = await ElectionModule.connect(user).declareAndCast( - amount, - proof, - candidates - ); - receipt = await tx.wait(); - } - - before('declare', async function () { - await declare(user1); - await declare(user2); - // Note: Intentionally not declaring for user3 - }); - - describe('when a user attempts to re-declare cross chain debt shares', function () { - it('reverts', async function () { - const { amount, proof } = merkleTree.claims[user1.address]; - - await assertRevert( - ElectionModule.declareCrossChainDebtShare(user1.address, amount, proof), - 'CrossChainDebtShareAlreadyDeclared' - ); - }); - }); - - it('emitted a CrossChainDebtShareDeclared event', async function () { - const event = findEvent({ - receipt, - eventName: 'CrossChainDebtShareDeclared', - }); - - assert.ok(event); - assertBn.equal(event.args.user, user2.address); - assertBn.equal( - event.args.debtShare, - expectedCrossChainDebtShare(user2.address, epoch.debtShareSnapshotId) - ); - }); - - it('shows that users have declared their cross chain debt shares', async function () { - assertBn.equal( - await ElectionModule.getDeclaredCrossChainDebtShare(user1.address), - expectedCrossChainDebtShare(user1.address, epoch.debtShareSnapshotId) - ); - assertBn.equal( - await ElectionModule.getDeclaredCrossChainDebtShare(user2.address), - expectedCrossChainDebtShare(user2.address, epoch.debtShareSnapshotId) - ); - }); - - it('shows that users have the expected vote power (cross chain component is now declared)', async function () { - assert.deepEqual( - await ElectionModule.getVotePower(user1.address), - expectedVotePower(user1.address, epoch.debtShareSnapshotId) - ); - assert.deepEqual( - await ElectionModule.getVotePower(user2.address), - expectedVotePower(user2.address, epoch.debtShareSnapshotId) - ); - }); - - describe('when a user tries to vote for more than one candidate', function () { - it('reverts', async function () { - await assertRevert( - ElectionModule.connect(user1).cast([user4.address, user5.address]), - 'TooManyCandidates' - ); - }); - }); - - describe('when users cast votes', function () { - let ballot1, ballot2, ballot3; - - before('vote', async function () { - await ElectionModule.connect(user1).cast([user4.address]); - await ElectionModule.connect(user2).cast([user4.address]); - await declareAndCast(user3, [user5.address]); // user3 didn't declare cross chain debt shares yet - await ElectionModule.connect(user4).cast([user6.address]); - await ElectionModule.connect(user5).cast([user4.address]); - }); - - before('identify ballots', async function () { - ballot1 = await ElectionModule.calculateBallotId([user4.address]); - ballot2 = await ElectionModule.calculateBallotId([user5.address]); - ballot3 = await ElectionModule.calculateBallotId([user6.address]); - }); - - it('keeps track of which ballot each user voted on', async function () { - assert.equal(await ElectionModule.getBallotVoted(user1.address), ballot1); - assert.equal(await ElectionModule.getBallotVoted(user2.address), ballot1); - assert.equal(await ElectionModule.getBallotVoted(user3.address), ballot2); - assert.equal(await ElectionModule.getBallotVoted(user4.address), ballot3); - assert.equal(await ElectionModule.getBallotVoted(user5.address), ballot1); - }); - - it('keeps track of the candidates of each ballot', async function () { - assert.deepEqual(await ElectionModule.getBallotCandidates(ballot1), [ - user4.address, - ]); - assert.deepEqual(await ElectionModule.getBallotCandidates(ballot2), [ - user5.address, - ]); - assert.deepEqual(await ElectionModule.getBallotCandidates(ballot3), [ - user6.address, - ]); - }); - - it('keeps track of vote power in each ballot', async function () { - const votesBallot1 = expectedVotePower( - user1.address, - epoch.debtShareSnapshotId - ) - .add(expectedVotePower(user2.address, epoch.debtShareSnapshotId)) - .add(expectedVotePower(user5.address, epoch.debtShareSnapshotId)); - const votesBallot2 = expectedVotePower( - user3.address, - epoch.debtShareSnapshotId - ); - const votesBallot3 = expectedVotePower( - user4.address, - epoch.debtShareSnapshotId - ); - - assertBn.equal(await ElectionModule.getBallotVotes(ballot1), votesBallot1); - assertBn.equal(await ElectionModule.getBallotVotes(ballot2), votesBallot2); - assertBn.equal(await ElectionModule.getBallotVotes(ballot3), votesBallot3); - }); - - describe('when voting ends', function () { - before('fast forward', async function () { - await fastForwardTo( - await ElectionModule.getEpochEndDate(), - ethers.provider - ); - }); - - it('shows that the current period is Evaluation', async function () { - assertBn.equal( - await ElectionModule.getCurrentPeriod(), - ElectionPeriod.Evaluation - ); - }); - - describe('when the election is evaluated', function () { - before('evaluate', async function () { - (await ElectionModule.evaluate(0)).wait(); - }); - - it('shows that the election is evaluated', async function () { - assert.equal(await ElectionModule.isElectionEvaluated(), true); - }); - - it('shows each candidates votes', async function () { - const votesUser4 = expectedVotePower( - user1.address, - epoch.debtShareSnapshotId - ) - .add(expectedVotePower(user2.address, epoch.debtShareSnapshotId)) - .add(expectedVotePower(user5.address, epoch.debtShareSnapshotId)); - const votesUser5 = expectedVotePower( - user3.address, - epoch.debtShareSnapshotId - ); - const votesUser6 = expectedVotePower( - user4.address, - epoch.debtShareSnapshotId - ); - - assertBn.equal( - await ElectionModule.getCandidateVotes(user4.address), - votesUser4 - ); - assertBn.equal( - await ElectionModule.getCandidateVotes(user5.address), - votesUser5 - ); - assertBn.equal( - await ElectionModule.getCandidateVotes(user6.address), - votesUser6 - ); - assertBn.equal( - await ElectionModule.getCandidateVotes(user7.address), - 0 - ); - assertBn.equal( - await ElectionModule.getCandidateVotes(user8.address), - 0 - ); - assertBn.equal( - await ElectionModule.getCandidateVotes(user9.address), - 0 - ); - }); - - it('shows the election winners', async function () { - assert.deepEqual( - await ElectionModule.getElectionWinners(), - epoch.winners() - ); - }); - - describe('when the election is resolved', function () { - before('resolve', async function () { - (await ElectionModule.resolve()).wait(); - }); - - it('shows the expected NFT owners', async function () { - const winners = epoch.winners(); - - assertBn.equal( - await CouncilToken.balanceOf(owner.address), - winners.includes(owner.address) ? 1 : 0 - ); - assertBn.equal( - await CouncilToken.balanceOf(user4.address), - winners.includes(user4.address) ? 1 : 0 - ); - assertBn.equal( - await CouncilToken.balanceOf(user5.address), - winners.includes(user5.address) ? 1 : 0 - ); - assertBn.equal( - await CouncilToken.balanceOf(user6.address), - winners.includes(user6.address) ? 1 : 0 - ); - assertBn.equal( - await CouncilToken.balanceOf(user7.address), - winners.includes(user7.address) ? 1 : 0 - ); - }); - }); - }); - }); - }); - }); - }); - }); - }); - }); - }); - }); - }); - }); -}); diff --git a/protocol/governance/test/contracts/modules/ElectionModule/Initialize.test.js b/protocol/governance/test/contracts/modules/ElectionModule/Initialize.test.js deleted file mode 100644 index c7d6e9b80c..0000000000 --- a/protocol/governance/test/contracts/modules/ElectionModule/Initialize.test.js +++ /dev/null @@ -1,112 +0,0 @@ -const { ethers } = hre; -const assert = require('assert/strict'); -const assertRevert = require('@synthetixio/core-utils/utils/assertions/assert-revert'); -const { daysToSeconds } = require('@synthetixio/core-utils/utils/misc/dates'); -const { getTime } = require('@synthetixio/core-utils/utils/hardhat/rpc'); -const { bootstrap } = require('@synthetixio/router/utils/tests'); -const initializer = require('@synthetixio/core-modules/test/helpers/initializer'); - -describe('SynthetixElectionModule (initialization)', () => { - const { proxyAddress } = bootstrap(initializer); - - let ElectionModule, DebtShare; - - let owner, user; - - let epochStartDate, epochEndDate, nominationPeriodStartDate, votingPeriodStartDate; - - before('identify signers', async () => { - [owner, user] = await ethers.getSigners(); - }); - - before('identify modules', async () => { - ElectionModule = await ethers.getContractAt( - 'contracts/modules/ElectionModule.sol:ElectionModule', - proxyAddress() - ); - }); - - describe('before initializing the module', function () { - it('shows that the module is not initialized', async () => { - assert.equal(await ElectionModule.isElectionModuleInitialized(), false); - }); - }); - - before('deploy debt shares mock', async function () { - const factory = await ethers.getContractFactory('DebtShareMock'); - DebtShare = await factory.deploy(); - }); - - describe('when initializing the module', function () { - describe('with an account that does not own the instance', function () { - it('reverts', async function () { - await assertRevert( - ElectionModule.connect(user)[ - 'initializeElectionModule(string,string,address[],uint8,uint64,uint64,uint64,address)' - ]('', '', [], 0, 0, 0, 0, DebtShare.address), - 'Unauthorized' - ); - }); - }); - - describe('with the account that owns the instance', function () { - describe('with the wrong initializer', function () { - it('reverts', async function () { - await assertRevert( - ElectionModule.connect(owner)[ - 'initializeElectionModule(string,string,address[],uint8,uint64,uint64,uint64)' - ]('', '', [owner.address], 1, 0, 0, 0), - 'WrongInitializer' - ); - }); - }); - - describe('with invalid parameters', function () { - describe('with invalid debtShareContract', function () { - it('reverts', async function () { - await assertRevert( - ElectionModule.connect(owner)[ - 'initializeElectionModule(string,string,address[],uint8,uint64,uint64,uint64,address)' - ]('', '', [owner.address], 1, 0, 0, 0, '0x0000000000000000000000000000000000000000'), - 'ZeroAddress' - ); - await assertRevert( - ElectionModule.connect(owner)[ - 'initializeElectionModule(string,string,address[],uint8,uint64,uint64,uint64,address)' - ]('', '', [owner.address], 1, 0, 0, 0, user.address), - 'NotAContract' - ); - }); - }); - }); - - describe('with valid parameters', function () { - before('initialize', async function () { - epochStartDate = await getTime(ethers.provider); - epochEndDate = epochStartDate + daysToSeconds(90); - votingPeriodStartDate = epochEndDate - daysToSeconds(7); - nominationPeriodStartDate = votingPeriodStartDate - daysToSeconds(7); - - const tx = await ElectionModule[ - 'initializeElectionModule(string,string,address[],uint8,uint64,uint64,uint64,address)' - ]( - 'Spartan Council Token', - 'SCT', - [owner.address, user.address], - 1, - nominationPeriodStartDate, - votingPeriodStartDate, - epochEndDate, - DebtShare.address - ); - - await tx.wait(); - }); - - it('set the debt share contract address', async function () { - assert.equal(await ElectionModule.getDebtShareContract(), DebtShare.address); - }); - }); - }); - }); -}); diff --git a/protocol/governance/test/contracts/modules/ElectionModule/helpers/debt-share-helper.js b/protocol/governance/test/contracts/modules/ElectionModule/helpers/debt-share-helper.js deleted file mode 100644 index 11bfbf7625..0000000000 --- a/protocol/governance/test/contracts/modules/ElectionModule/helpers/debt-share-helper.js +++ /dev/null @@ -1,133 +0,0 @@ -const { ethers } = hre; -const { bnSqrt } = require('@synthetixio/core-utils/utils/ethers/bignumber'); -const { parseBalanceMap } = require('@synthetixio/core-utils/utils/merkle-tree/parse-balance-tree'); - -let _debtShareData = {}; -let _crossChainDebtShareData = {}; - -async function simulateDebtShareData(DebtShare, users) { - const [user1, user2, user3, user4, user5] = users; - - _debtShareData = { - 42: { - [user1.address]: ethers.utils.parseEther('1000'), - [user2.address]: ethers.utils.parseEther('24000'), - [user3.address]: ethers.utils.parseEther('200000'), - [user4.address]: ethers.utils.parseEther('30000'), - [user5.address]: ethers.utils.parseEther('20'), - }, - 1337: { - [user1.address]: ethers.utils.parseEther('0'), - [user2.address]: ethers.utils.parseEther('30000'), - [user3.address]: ethers.utils.parseEther('21000'), - [user4.address]: ethers.utils.parseEther('459000'), - [user5.address]: ethers.utils.parseEther('100'), - }, - 2192: { - [user1.address]: ethers.utils.parseEther('500'), - [user2.address]: ethers.utils.parseEther('10'), - [user3.address]: ethers.utils.parseEther('2500'), - [user4.address]: ethers.utils.parseEther('50000'), - [user5.address]: ethers.utils.parseEther('1'), - }, - }; - - async function simulateDebtShareBalance(user, balance, periodId) { - const tx = await DebtShare.setBalanceOfOnPeriod(user.address, balance, periodId); - await tx.wait(); - } - - async function simulateDebtShareBalances(periodId) { - await simulateDebtShareBalance(user1, _debtShareData[periodId][user1.address], periodId); - await simulateDebtShareBalance(user2, _debtShareData[periodId][user2.address], periodId); - await simulateDebtShareBalance(user3, _debtShareData[periodId][user3.address], periodId); - await simulateDebtShareBalance(user4, _debtShareData[periodId][user4.address], periodId); - await simulateDebtShareBalance(user5, _debtShareData[periodId][user5.address], periodId); - } - - await simulateDebtShareBalances('42'); - await simulateDebtShareBalances('1337'); - await simulateDebtShareBalances('2192'); -} - -async function simulateCrossChainDebtShareData(users) { - const [user1, user2, user3] = users; - - _crossChainDebtShareData = { - 42: { - [user1.address]: ethers.utils.parseEther('1000000'), - [user2.address]: ethers.utils.parseEther('240000'), - [user3.address]: ethers.utils.parseEther('1000'), - }, - 1337: { - [user1.address]: ethers.utils.parseEther('205000'), - [user2.address]: ethers.utils.parseEther('300000'), - [user3.address]: ethers.utils.parseEther('2100'), - }, - 2192: { - [user1.address]: ethers.utils.parseEther('1'), - [user2.address]: ethers.utils.parseEther('35000'), - [user3.address]: ethers.utils.parseEther('250000'), - }, - 666: { - [user1.address]: ethers.utils.parseEther('666'), - [user2.address]: ethers.utils.parseEther('666'), - [user3.address]: ethers.utils.parseEther('666'), - }, - }; - - function stringifyBalances(balances) { - Object.keys(balances).forEach((user) => (balances[user] = balances[user].toString())); - - return balances; - } - - _crossChainDebtShareData[42].merkleTree = parseBalanceMap( - stringifyBalances(_crossChainDebtShareData[42]) - ); - _crossChainDebtShareData[1337].merkleTree = parseBalanceMap( - stringifyBalances(_crossChainDebtShareData[1337]) - ); - _crossChainDebtShareData[2192].merkleTree = parseBalanceMap( - stringifyBalances(_crossChainDebtShareData[2192]) - ); - _crossChainDebtShareData[666].merkleTree = parseBalanceMap( - stringifyBalances(_crossChainDebtShareData[666]) - ); -} - -function expectedDebtShare(user, periodId) { - if (!_debtShareData[periodId] || !_debtShareData[periodId][user]) { - return 0; - } - - return _debtShareData[periodId][user]; -} - -function expectedCrossChainDebtShare(user, periodId) { - if (!_crossChainDebtShareData[periodId] || !_crossChainDebtShareData[periodId][user]) { - return 0; - } - - return _crossChainDebtShareData[periodId][user]; -} - -function expectedVotePower(user, periodId) { - const debtShare = expectedDebtShare(user, periodId); - const crossChainDebtShare = expectedCrossChainDebtShare(user, periodId); - - return bnSqrt(debtShare.add(crossChainDebtShare)); -} - -function getCrossChainMerkleTree(periodId) { - return _crossChainDebtShareData[periodId].merkleTree; -} - -module.exports = { - simulateDebtShareData, - simulateCrossChainDebtShareData, - expectedDebtShare, - expectedCrossChainDebtShare, - expectedVotePower, - getCrossChainMerkleTree, -}; diff --git a/protocol/governance/test/contracts/modules/UpgradeModule.test.js b/protocol/governance/test/contracts/modules/UpgradeModule.test.js deleted file mode 100644 index f9ca82cdda..0000000000 --- a/protocol/governance/test/contracts/modules/UpgradeModule.test.js +++ /dev/null @@ -1,46 +0,0 @@ -const assert = require('assert/strict'); -const assertRevert = require('@synthetixio/core-utils/utils/assertions/assert-revert'); - -const { bootstrap } = require('@synthetixio/router/utils/tests'); -const initializer = require('@synthetixio/core-modules/test/helpers/initializer'); - -const { ethers } = hre; - -describe('UpgradeModule', function () { - describe('when upgrading to a new Proxy', function () { - const { proxyAddress, routerAddress } = bootstrap(initializer); - - let UpgradeModule; - before('identify module', async function () { - UpgradeModule = await ethers.getContractAt( - 'contracts/modules/UpgradeModule.sol:UpgradeModule', - proxyAddress() - ); - }); - - describe('when attempting to set the implementation with a non owner signer', function () { - it('reverts', async function () { - const [, user] = await ethers.getSigners(); - - await assertRevert(UpgradeModule.connect(user).upgradeTo(user.address), 'Unauthorized'); - }); - }); - - describe('when upgrading the Router implementation', function () { - let NewRouter; - before('prepare modules', async function () { - const factory = await ethers.getContractFactory('Router'); - NewRouter = await factory.deploy(); - }); - - it('sets the new address', async function () { - assert.equal(await UpgradeModule.getImplementation(), routerAddress()); - - const tx = await UpgradeModule.upgradeTo(NewRouter.address); - await tx.wait(); - - assert.equal(await UpgradeModule.getImplementation(), NewRouter.address); - }); - }); - }); -}); diff --git a/protocol/governance/test/helpers/debt-share-helper.ts b/protocol/governance/test/helpers/debt-share-helper.ts new file mode 100644 index 0000000000..0ea28a95a2 --- /dev/null +++ b/protocol/governance/test/helpers/debt-share-helper.ts @@ -0,0 +1,125 @@ +import { bnSqrt } from '@synthetixio/core-utils/utils/ethers/bignumber'; +import { parseBalanceMap } from '@synthetixio/core-utils/utils/merkle-tree/parse-balance-tree'; +import { ethers } from 'ethers'; + +import type { DebtShareMock } from '../generated/typechain'; + +interface DebtShareData { + [chainId: number]: { + [address: string]: ethers.BigNumber; + }; +} + +interface MerkleTreeData { + [chainId: number]: ReturnType; +} + +let _debtShareData: DebtShareData = {}; +let _crossChainDebtShareData: DebtShareData = {}; +const _crossChainMerkleTreeData: MerkleTreeData = {}; + +export async function simulateDebtShareData(DebtShare: DebtShareMock, users: ethers.Signer[]) { + const addresses = await Promise.all(users.map((u) => u.getAddress())); + + _debtShareData = { + 42: { + [addresses[0]]: ethers.utils.parseEther('1000'), + [addresses[1]]: ethers.utils.parseEther('24000'), + [addresses[2]]: ethers.utils.parseEther('200000'), + [addresses[3]]: ethers.utils.parseEther('30000'), + [addresses[4]]: ethers.utils.parseEther('20'), + }, + 1337: { + [addresses[0]]: ethers.utils.parseEther('0'), + [addresses[1]]: ethers.utils.parseEther('30000'), + [addresses[2]]: ethers.utils.parseEther('21000'), + [addresses[3]]: ethers.utils.parseEther('459000'), + [addresses[4]]: ethers.utils.parseEther('100'), + }, + 2192: { + [addresses[0]]: ethers.utils.parseEther('500'), + [addresses[1]]: ethers.utils.parseEther('10'), + [addresses[2]]: ethers.utils.parseEther('2500'), + [addresses[3]]: ethers.utils.parseEther('50000'), + [addresses[4]]: ethers.utils.parseEther('1'), + }, + }; + + for (const periodId of Object.keys(_debtShareData)) { + const balances = _debtShareData[Number.parseInt(periodId)]! as DebtShareData[number]; + for (const address of Object.keys(balances)) { + const balance = balances[address]; + const tx = await DebtShare.setBalanceOfOnPeriod(address, balance, periodId); + await tx.wait(); + } + } +} + +export async function simulateCrossChainDebtShareData(users: ethers.Signer[]) { + const addresses = await Promise.all(users.map((u) => u.getAddress())); + + _crossChainDebtShareData = { + 42: { + [addresses[0]]: ethers.utils.parseEther('1000000'), + [addresses[1]]: ethers.utils.parseEther('240000'), + [addresses[2]]: ethers.utils.parseEther('1000'), + }, + 1337: { + [addresses[0]]: ethers.utils.parseEther('205000'), + [addresses[1]]: ethers.utils.parseEther('300000'), + [addresses[2]]: ethers.utils.parseEther('2100'), + }, + 2192: { + [addresses[0]]: ethers.utils.parseEther('1'), + [addresses[1]]: ethers.utils.parseEther('35000'), + [addresses[2]]: ethers.utils.parseEther('250000'), + }, + 666: { + [addresses[0]]: ethers.utils.parseEther('666'), + [addresses[1]]: ethers.utils.parseEther('666'), + [addresses[2]]: ethers.utils.parseEther('666'), + }, + }; + + for (const key of Object.keys(_crossChainDebtShareData)) { + const periodId = Number.parseInt(key); + _crossChainMerkleTreeData[periodId] = parseBalanceMap( + _stringifyBalances(_crossChainDebtShareData[periodId]) + ); + } +} + +function _stringifyBalances(balances: DebtShareData[number]) { + return Object.fromEntries( + Object.entries(balances).map(([user, balance]) => [user, balance.toString()]) + ); +} + +export async function expectedDebtShare( + user: ethers.Signer, + periodId: number +): Promise { + const address = await user.getAddress(); + return _debtShareData[periodId]?.[address] || ethers.BigNumber.from(0); +} + +export async function expectedCrossChainDebtShare( + user: ethers.Signer, + periodId: number +): Promise { + const address = await user.getAddress(); + return _crossChainDebtShareData[periodId]?.[address] || ethers.BigNumber.from(0); +} + +export async function expectedVotePower( + user: ethers.Signer, + periodId: number +): Promise { + const debtShare = await expectedDebtShare(user, periodId); + const crossChainDebtShare = await expectedCrossChainDebtShare(user, periodId); + return bnSqrt(debtShare.add(crossChainDebtShare)); +} + +export function getCrossChainMerkleTree(periodId: number) { + return _crossChainMerkleTreeData[periodId]!; +} diff --git a/protocol/governance/test/helpers/object.ts b/protocol/governance/test/helpers/object.ts new file mode 100644 index 0000000000..724204ddf6 --- /dev/null +++ b/protocol/governance/test/helpers/object.ts @@ -0,0 +1,13 @@ +type Obj = { [k: string]: unknown }; + +type Entry = { + [K in keyof T]: [K, T[K]]; +}[keyof T]; + +export function typedValues(obj: T) { + return Object.values(obj as Obj) as T[keyof T][]; +} + +export function typedEntries(obj: T) { + return Object.entries(obj as Obj) as Entry[]; +} diff --git a/protocol/governance/test/helpers/spin-chain.ts b/protocol/governance/test/helpers/spin-chain.ts new file mode 100644 index 0000000000..cdc6805e74 --- /dev/null +++ b/protocol/governance/test/helpers/spin-chain.ts @@ -0,0 +1,104 @@ +import path from 'node:path'; +import { cannonBuild, cannonInspect } from '@synthetixio/core-modules/test/helpers/cannon'; +import { ethers } from 'ethers'; +import hre from 'hardhat'; +import { glob, runTypeChain } from 'typechain'; + +import type { CcipRouterMock, CouncilToken } from '../generated/typechain/sepolia'; +import type { SnapshotRecordMock } from '../generated/typechain/sepolia'; + +export async function spinChain({ + networkName, + cannonfile, + writeDeployments, + typechainFolder, + chainSlector, + ownerAddress = '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266', +}: { + networkName: string; + cannonfile: string; + writeDeployments: string; + typechainFolder: string; + chainSlector: string; + ownerAddress?: string; +}) { + if (!hre.config.networks[networkName]) { + throw new Error(`Invalid network "${networkName}"`); + } + + const { chainId } = hre.config.networks[networkName]; + + if (typeof chainId !== 'number') { + throw new Error(`Invalid chainId on network ${networkName}`); + } + + writeDeployments = path.join(writeDeployments, networkName); + typechainFolder = path.join(typechainFolder, networkName); + + console.log(` Building: ${cannonfile} - Network: ${networkName}`); + + const { packageRef, provider, outputs } = await cannonBuild({ + cannonfile: path.join(hre.config.paths.root, cannonfile), + chainId, + impersonate: ownerAddress, + wipe: true, + getArtifact: async (contractName: string) => + await hre.run('cannon:get-artifact', { name: contractName }), + pkgInfo: require(path.join(hre.config.paths.root, 'package.json')), + projectDirectory: hre.config.paths.root, + }); + + await cannonInspect({ + chainId, + packageRef, + writeDeployments, + }); + + const allFiles = glob(hre.config.paths.root, [`${writeDeployments}/**/*.json`]); + + await runTypeChain({ + cwd: hre.config.paths.root, + filesToProcess: allFiles, + allFiles, + target: 'ethers-v5', + outDir: typechainFolder, + }); + + const signer = provider.getSigner(ownerAddress); + + const CoreProxy = new ethers.Contract( + outputs.contracts!.CoreProxy.address, + outputs.contracts!.CoreProxy.abi, + signer + ) as CoreProxy; + + const SnapshotRecordMock = new ethers.Contract( + outputs.contracts!.SnapshotRecordMock.address, + outputs.contracts!.SnapshotRecordMock.abi, + signer + ) as SnapshotRecordMock; + + const CcipRouter = new ethers.Contract( + outputs.contracts!.CcipRouterMock.address, + outputs.contracts!.CcipRouterMock.abi, + signer + ) as CcipRouterMock; + + const CouncilToken = new ethers.Contract( + outputs.contracts!.CouncilToken.address, + outputs.contracts!.CouncilToken.abi, + signer + ) as CouncilToken; + + return { + networkName, + chainId, + chainSlector, + provider: provider as unknown as ethers.providers.JsonRpcProvider, + CoreProxy, + CouncilToken, + CcipRouter, + signer, + SnapshotRecordMock, + }; +} diff --git a/protocol/governance/test/integration/CrossChainElections.test.ts b/protocol/governance/test/integration/CrossChainElections.test.ts new file mode 100644 index 0000000000..46bdb77c63 --- /dev/null +++ b/protocol/governance/test/integration/CrossChainElections.test.ts @@ -0,0 +1,159 @@ +import { ccipReceive } from '@synthetixio/core-modules/test/helpers/ccip'; +import assertBn from '@synthetixio/core-utils/utils/assertions/assert-bignumber'; +import assertRevert from '@synthetixio/core-utils/utils/assertions/assert-revert'; +import assert from 'assert'; +import { ethers } from 'ethers'; +import { ElectionPeriod } from '../constants'; +import { typedEntries, typedValues } from '../helpers/object'; +import { ChainSelector, integrationBootstrap, SignerOnChains } from './bootstrap'; + +describe('cross chain election testing', function () { + const { chains, fixtureSignerOnChains, fastForwardChainsTo } = integrationBootstrap(); + + const fastForwardToNominationPeriod = async () => { + const schedule = await chains.mothership.CoreProxy.getEpochSchedule(); + await fastForwardChainsTo(schedule.nominationPeriodStartDate.toNumber() + 10); + }; + + const fastForwardToVotingPeriod = async () => { + const schedule = await chains.mothership.CoreProxy.getEpochSchedule(); + await fastForwardChainsTo(schedule.votingPeriodStartDate.toNumber() + 10); + }; + + let nominee: SignerOnChains; + let voter: SignerOnChains; + + before('set up users', async function () { + nominee = await fixtureSignerOnChains(); + voter = await fixtureSignerOnChains(); + }); + + describe('on initialization', function () { + it('shows that the current period is Administration', async function () { + assertBn.equal( + await chains.mothership.CoreProxy.getCurrentPeriod(), + ElectionPeriod.Administration + ); + }); + + it('the current epoch index is correct', async function () { + assertBn.equal(await chains.mothership.CoreProxy.getEpochIndex(), 0); + }); + }); + + describe('expected reverts', function () { + it('cast will fail if not in voting period', async function () { + const randomCandidate = ethers.Wallet.createRandom().address; + + await assertRevert( + chains.satellite1.CoreProxy.connect(voter.satellite1).cast( + [randomCandidate], + [1000000000], + { + value: ethers.utils.parseUnits('0.05', 'gwei'), + } + ), + 'NotCallableInCurrentPeriod' + ); + }); + }); + + describe('successful voting on all chains', function () { + before('prepare snapshot record on all chains', async function () { + for (const chain of typedValues(chains)) { + await chain.CoreProxy.connect(chain.signer).setSnapshotContract( + chain.SnapshotRecordMock.address, + true + ); + } + }); + + before('nominate', async function () { + await fastForwardToNominationPeriod(); + await chains.mothership.CoreProxy.connect(nominee.mothership).nominate(); + }); + + before('preapare ballots on all chains', async function () { + await fastForwardToVotingPeriod(); + + for (const [chainName, chain] of typedEntries(chains)) { + const snapshotId1 = await chain.CoreProxy.callStatic.takeVotePowerSnapshot( + chain.SnapshotRecordMock.address // TODO: should we remove this param? it is being set on setSnapshotContract + ); + await chain.CoreProxy.takeVotePowerSnapshot(chain.SnapshotRecordMock.address); + await chain.SnapshotRecordMock.setBalanceOfOnPeriod( + await voter[chainName].getAddress(), + ethers.utils.parseEther('100'), + snapshotId1.add(1).toString() + ); + await chain.CoreProxy.prepareBallotWithSnapshot( + chain.SnapshotRecordMock.address, + await voter[chainName].getAddress() + ); + } + }); + + it('casts vote on mothership', async function () { + const { mothership } = chains; + + const tx = await mothership.CoreProxy.connect(voter.mothership).cast( + [await nominee.mothership.getAddress()], + [ethers.utils.parseEther('100')] + ); + await tx.wait(); + + const hasVoted = await mothership.CoreProxy.hasVoted( + await voter.mothership.getAddress(), + mothership.chainId + ); + + assert.equal(hasVoted, true); + }); + + it('casts vote on satellite1', async function () { + const { mothership, satellite1 } = chains; + + const tx = await satellite1.CoreProxy.connect(voter.satellite1).cast( + [await nominee.mothership.getAddress()], + [ethers.utils.parseEther('100')] + ); + const rx = await tx.wait(); + await ccipReceive({ + rx, + sourceChainSelector: ChainSelector.satellite1, + targetSigner: voter.mothership, + ccipAddress: mothership.CcipRouter.address, + }); + + const hasVoted = await mothership.CoreProxy.hasVoted( + await voter.satellite1.getAddress(), + satellite1.chainId + ); + + assert.equal(hasVoted, true); + }); + + it('casts vote on satellite2', async function () { + const { mothership, satellite2 } = chains; + + const tx = await satellite2.CoreProxy.connect(voter.satellite2).cast( + [await nominee.mothership.getAddress()], + [ethers.utils.parseEther('100')] + ); + const rx = await tx.wait(); + await ccipReceive({ + rx, + sourceChainSelector: ChainSelector.satellite2, + targetSigner: voter.mothership, + ccipAddress: mothership.CcipRouter.address, + }); + + const hasVoted = await mothership.CoreProxy.hasVoted( + await voter.satellite2.getAddress(), + satellite2.chainId + ); + + assert.equal(hasVoted, true); + }); + }); +}); diff --git a/protocol/governance/test/integration/CrossChainNFTDistribution.test.ts b/protocol/governance/test/integration/CrossChainNFTDistribution.test.ts new file mode 100644 index 0000000000..fccb098454 --- /dev/null +++ b/protocol/governance/test/integration/CrossChainNFTDistribution.test.ts @@ -0,0 +1,50 @@ +import assertRevert from '@synthetixio/core-utils/utils/assertions/assert-revert'; +import assert from 'assert'; +import { integrationBootstrap } from './bootstrap'; + +describe('cross chain nft distribution', function () { + const { chains, fixtureSignerOnChains } = integrationBootstrap(); + + const nftToken = { tokenName: 'TESTNFT', tokenSymbol: '$TN', uri: 'https://google.com' }; + + it('NFT Module is not initialized', async function () { + for (const chain of Object.values(chains)) { + assert.equal(await chain.CoreProxy.isInitialized(), false); + } + }); + + it('distributes NFTS after election', async function () { + for (const chain of Object.values(chains)) { + await chain.CoreProxy.initialize(nftToken.tokenName, nftToken.tokenSymbol, nftToken.uri); + assert.equal(await chain.CoreProxy.isInitialized(), true); + } + }); + + it('allows onwer mint nft', async function () { + for (const chain of Object.values(chains)) { + const ownerAddress = await chain.signer.getAddress(); + await chain.CoreProxy.mint(ownerAddress, 1); + assert.equal((await chain.CoreProxy.balanceOf(ownerAddress)).toString(), '1'); + } + }); + + it('allows owner burn nft', async function () { + for (const chain of Object.values(chains)) { + const ownerAddress = await chain.signer.getAddress(); + await chain.CoreProxy.burn(1); + assert.equal((await chain.CoreProxy.balanceOf(ownerAddress)).toString(), '0'); + } + }); + + it('random user cant mint', async function () { + const randomUser = await fixtureSignerOnChains(); + + for (const [chainName, chain] of Object.entries(chains)) { + const user = randomUser[chainName as keyof typeof chains]; + await assertRevert( + chain.CoreProxy.connect(user).mint(await chain.signer.getAddress(), 1), + 'Unauthorized' + ); + } + }); +}); diff --git a/protocol/governance/test/integration/Elections.test.ts b/protocol/governance/test/integration/Elections.test.ts new file mode 100644 index 0000000000..4971e336c4 --- /dev/null +++ b/protocol/governance/test/integration/Elections.test.ts @@ -0,0 +1,808 @@ +import assert from 'node:assert/strict'; +import { ccipReceive } from '@synthetixio/core-modules/test/helpers/ccip'; +import assertBn from '@synthetixio/core-utils/utils/assertions/assert-bignumber'; +import assertEvent from '@synthetixio/core-utils/utils/assertions/assert-event'; +import assertRevert from '@synthetixio/core-utils/utils/assertions/assert-revert'; +import { fastForwardTo } from '@synthetixio/core-utils/utils/hardhat/rpc'; +import { ethers } from 'ethers'; +import { ElectionPeriod } from '../constants'; +import { ChainSelector, integrationBootstrap } from './bootstrap'; + +function generateRandomAddresses() { + const wallets = []; + for (let i = 0; i < 10; i++) { + wallets.push(ethers.Wallet.createRandom()); + } + return wallets; +} + +describe('SynthetixElectionModule - Elections', () => { + const { chains } = integrationBootstrap(); + + const fastForwardToNominationPeriod = async (provider: ethers.providers.JsonRpcProvider) => { + const schedule = await chains.mothership.CoreProxy.getEpochSchedule(); + await fastForwardTo(schedule.nominationPeriodStartDate.toNumber() + 10, provider); + }; + + const fastForwardToVotingPeriod = async (provider: ethers.providers.JsonRpcProvider) => { + const schedule = await chains.mothership.CoreProxy.getEpochSchedule(); + await fastForwardTo(schedule.votingPeriodStartDate.toNumber() + 10, provider); + }; + + const fastForwardToEvaluationPeriod = async (provider: ethers.providers.JsonRpcProvider) => { + const schedule = await chains.mothership.CoreProxy.getEpochSchedule(); + await fastForwardTo(schedule.endDate.add(10).toNumber(), provider); + }; + + const addresses = generateRandomAddresses(); + + const epochs = [ + { + index: 0, + blockNumber: 21000000, + winners: () => [addresses[3].address], + }, + { + index: 1, + blockNumber: 23100007, + winners: () => [addresses[3].address], + }, + { + index: 2, + blockNumber: 30043001, + winners: () => [addresses[5].address], + }, + ]; + + before('set snapshot contract', async () => { + const { mothership, satellite1, satellite2 } = chains; + await mothership.CoreProxy.setSnapshotContract(mothership.SnapshotRecordMock.address, true); + await satellite1.CoreProxy.setSnapshotContract(satellite1.SnapshotRecordMock.address, true); + await satellite2.CoreProxy.setSnapshotContract(satellite2.SnapshotRecordMock.address, true); + }); + + before('fund addresses', async () => { + await Promise.all( + Object.values(chains).map(async (chain) => { + return addresses.map(async (wallet) => { + return await chain.provider.send('hardhat_setBalance', [ + wallet.address, + `0x${(1e22).toString(16)}`, + ]); + }); + }) + ); + }); + + describe('when the election module is initialized', async () => { + epochs.forEach((epoch) => { + describe(`epoch ${epoch.index}`, () => { + it(`shows that the current epoch index is ${epoch.index}`, async () => { + assertBn.equal(await chains.mothership.CoreProxy.getEpochIndex(), epoch.index); + }); + + it('shows that the current period is Administration', async () => { + assertBn.equal( + await chains.mothership.CoreProxy.getCurrentPeriod(), + ElectionPeriod.Administration + ); + }); + + describe('when trying to retrieve the current debt share of a user', () => { + it('returns zero', async () => { + assertBn.equal( + await chains.mothership.CoreProxy.getVotePower(addresses[0].address, 1115111, 0), + 0 + ); + assertBn.equal( + await chains.mothership.CoreProxy.getVotePower(addresses[0].address, 420, 0), + 0 + ); + assertBn.equal( + await chains.mothership.CoreProxy.getVotePower(addresses[0].address, 43113, 0), + 0 + ); + }); + }); + }); + + describe('before the nomination period begins', () => { + describe('when trying to set the debt share id', () => { + it('reverts', async () => { + const { mothership, satellite1, satellite2 } = chains; + await assertRevert( + mothership.CoreProxy.takeVotePowerSnapshot(mothership.SnapshotRecordMock.address), + 'NotCallableInCurrentPeriod' + ); + await assertRevert( + satellite1.CoreProxy.takeVotePowerSnapshot(satellite1.SnapshotRecordMock.address), + 'NotCallableInCurrentPeriod' + ); + await assertRevert( + satellite1.CoreProxy.takeVotePowerSnapshot(satellite2.SnapshotRecordMock.address), + 'NotCallableInCurrentPeriod' + ); + }); + }); + + describe('when trying to prepare the ballot with snapshots', () => { + it('reverts', async () => { + const { mothership, satellite1, satellite2 } = chains; + await assertRevert( + mothership.CoreProxy.prepareBallotWithSnapshot( + mothership.SnapshotRecordMock.address, + addresses[0].address + ), + 'NotCallableInCurrentPeriod' + ); + await assertRevert( + satellite1.CoreProxy.prepareBallotWithSnapshot( + satellite1.SnapshotRecordMock.address, + addresses[0].address + ), + 'NotCallableInCurrentPeriod' + ); + await assertRevert( + satellite2.CoreProxy.prepareBallotWithSnapshot( + satellite2.SnapshotRecordMock.address, + addresses[0].address + ), + 'NotCallableInCurrentPeriod' + ); + }); + }); + }); + + describe('when advancing to the nominations period', () => { + let snapshotId: ethers.BigNumber, + snapshotId1: ethers.BigNumber, + snapshotId2: ethers.BigNumber; + it('fast forward', async () => { + const { mothership, satellite1, satellite2 } = chains; + await fastForwardToNominationPeriod(mothership.provider); + await fastForwardToNominationPeriod(satellite1.provider); + await fastForwardToNominationPeriod(satellite2.provider); + }); + + describe('when trying to set the snapshot contract', () => { + it('reverts', async () => { + const { mothership, satellite1, satellite2 } = chains; + await assertRevert( + mothership.CoreProxy.setSnapshotContract(mothership.SnapshotRecordMock.address, true), + 'NotCallableInCurrentPeriod' + ); + await assertRevert( + satellite1.CoreProxy.setSnapshotContract(satellite1.SnapshotRecordMock.address, true), + 'NotCallableInCurrentPeriod' + ); + await assertRevert( + satellite2.CoreProxy.setSnapshotContract(satellite2.SnapshotRecordMock.address, true), + 'NotCallableInCurrentPeriod' + ); + }); + }); + + it('simulate debt share data', async () => { + const { mothership, satellite1, satellite2 } = chains; + + snapshotId = await mothership.CoreProxy.callStatic.takeVotePowerSnapshot( + mothership.SnapshotRecordMock.address + ); + + snapshotId = snapshotId.add(1); + + await mothership.CoreProxy.takeVotePowerSnapshot(mothership.SnapshotRecordMock.address); + + await mothership.SnapshotRecordMock.setBalanceOfOnPeriod( + addresses[0].address, + ethers.utils.parseEther('100'), + snapshotId.toString() + ); + await mothership.SnapshotRecordMock.setBalanceOfOnPeriod( + addresses[1].address, + ethers.utils.parseEther('100'), + snapshotId.toString() + ); + await mothership.SnapshotRecordMock.setBalanceOfOnPeriod( + addresses[2].address, + ethers.utils.parseEther('100'), + snapshotId.toString() + ); + await mothership.SnapshotRecordMock.setBalanceOfOnPeriod( + addresses[3].address, + ethers.utils.parseEther('100'), + snapshotId.toString() + ); + await mothership.SnapshotRecordMock.setBalanceOfOnPeriod( + addresses[4].address, + ethers.utils.parseEther('100'), + snapshotId.toString() + ); + + //prepare voting for satellite1 + snapshotId1 = await satellite1.CoreProxy.callStatic.takeVotePowerSnapshot( + satellite1.SnapshotRecordMock.address + ); + + snapshotId1 = snapshotId1.add(1); + + await satellite1.CoreProxy.takeVotePowerSnapshot(satellite1.SnapshotRecordMock.address); + + await satellite1.SnapshotRecordMock.setBalanceOfOnPeriod( + addresses[0].address, + ethers.utils.parseEther('100'), + snapshotId1.toString() + ); + await satellite1.SnapshotRecordMock.setBalanceOfOnPeriod( + addresses[1].address, + ethers.utils.parseEther('100'), + snapshotId1.toString() + ); + await satellite1.SnapshotRecordMock.setBalanceOfOnPeriod( + addresses[2].address, + ethers.utils.parseEther('100'), + snapshotId1.toString() + ); + await satellite1.SnapshotRecordMock.setBalanceOfOnPeriod( + addresses[3].address, + ethers.utils.parseEther('100'), + snapshotId1.toString() + ); + await satellite1.SnapshotRecordMock.setBalanceOfOnPeriod( + addresses[4].address, + ethers.utils.parseEther('100'), + snapshotId1.toString() + ); + + //prepare voting for satellite2 + snapshotId2 = await satellite2.CoreProxy.callStatic.takeVotePowerSnapshot( + satellite2.SnapshotRecordMock.address + ); + + snapshotId2 = snapshotId2.add(1); + + await satellite2.CoreProxy.takeVotePowerSnapshot(satellite2.SnapshotRecordMock.address); + + await satellite2.SnapshotRecordMock.setBalanceOfOnPeriod( + addresses[0].address, + ethers.utils.parseEther('100'), + snapshotId2.toString() + ); + await satellite2.SnapshotRecordMock.setBalanceOfOnPeriod( + addresses[1].address, + ethers.utils.parseEther('100'), + snapshotId2.toString() + ); + await satellite2.SnapshotRecordMock.setBalanceOfOnPeriod( + addresses[2].address, + ethers.utils.parseEther('100'), + snapshotId2.toString() + ); + await satellite2.SnapshotRecordMock.setBalanceOfOnPeriod( + addresses[3].address, + ethers.utils.parseEther('100'), + snapshotId2.toString() + ); + await satellite2.SnapshotRecordMock.setBalanceOfOnPeriod( + addresses[4].address, + ethers.utils.parseEther('100'), + snapshotId2.toString() + ); + }); + + it('shows that the current period is Nomination', async () => { + assertBn.equal( + await chains.mothership.CoreProxy.getCurrentPeriod(), + ElectionPeriod.Nomination + ); + }); + + it('shows that the snapshot id is set', async () => { + const { mothership, satellite1, satellite2 } = chains; + + const contractSnapShotIdMotherShip = await mothership.CoreProxy.getVotePowerSnapshotId( + mothership.SnapshotRecordMock.address, + epoch.index + ); + const contractSnapShotIdSatellite1 = await satellite1.CoreProxy.getVotePowerSnapshotId( + satellite1.SnapshotRecordMock.address, + epoch.index + ); + const contractSnapShotIdSatellite2 = await satellite2.CoreProxy.getVotePowerSnapshotId( + satellite2.SnapshotRecordMock.address, + epoch.index + ); + + assertBn.equal(contractSnapShotIdMotherShip, snapshotId); + + assertBn.equal(contractSnapShotIdSatellite1, snapshotId1); + + assertBn.equal(contractSnapShotIdSatellite2, snapshotId2); + }); + + describe('when cross chain debt share data is collected', () => { + before('nominate', async () => { + const { mothership } = chains; + + await ( + await mothership.CoreProxy.connect( + addresses[1].connect(mothership.provider) + ).nominate() + ).wait(); + await ( + await mothership.CoreProxy.connect( + addresses[3].connect(mothership.provider) + ).nominate() + ).wait(); + await ( + await mothership.CoreProxy.connect( + addresses[4].connect(mothership.provider) + ).nominate() + ).wait(); + await ( + await mothership.CoreProxy.connect( + addresses[5].connect(mothership.provider) + ).nominate() + ).wait(); + await ( + await mothership.CoreProxy.connect( + addresses[6].connect(mothership.provider) + ).nominate() + ).wait(); + await ( + await mothership.CoreProxy.connect( + addresses[7].connect(mothership.provider) + ).nominate() + ).wait(); + await ( + await mothership.CoreProxy.connect( + addresses[8].connect(mothership.provider) + ).nominate() + ).wait(); + }); + + it('is nominated', async () => { + const { mothership } = chains; + assert.equal(await mothership.CoreProxy.isNominated(addresses[3].address), true); + assert.equal(await mothership.CoreProxy.isNominated(addresses[4].address), true); + assert.equal(await mothership.CoreProxy.isNominated(addresses[5].address), true); + assert.equal(await mothership.CoreProxy.isNominated(addresses[6].address), true); + assert.equal(await mothership.CoreProxy.isNominated(addresses[7].address), true); + assert.equal(await mothership.CoreProxy.isNominated(addresses[8].address), true); + }); + + describe('when users declare their debt shares in the wrong period', () => { + it('reverts', async () => { + const { mothership, satellite2 } = chains; + await assertRevert( + mothership.CoreProxy.prepareBallotWithSnapshot( + mothership.SnapshotRecordMock.address, + addresses[0].address + ), + 'NotCallableInCurrentPeriod' + ); + await assertRevert( + satellite2.CoreProxy.prepareBallotWithSnapshot( + mothership.SnapshotRecordMock.address, + addresses[0].address + ), + 'NotCallableInCurrentPeriod' + ); + await assertRevert( + satellite2.CoreProxy.prepareBallotWithSnapshot( + satellite2.SnapshotRecordMock.address, + addresses[0].address + ), + 'NotCallableInCurrentPeriod' + ); + }); + }); + + describe('when advancing to the voting period', () => { + before('fast forward', async () => { + const { mothership, satellite1, satellite2 } = chains; + await fastForwardToVotingPeriod(mothership.provider); + await fastForwardToVotingPeriod(satellite1.provider); + await fastForwardToVotingPeriod(satellite2.provider); + }); + + it('shows that the current period is Voting', async () => { + assertBn.equal( + await chains.mothership.CoreProxy.getCurrentPeriod(), + ElectionPeriod.Vote + ); + }); + + describe('when users declare their cross chain debt shares incorrectly', () => { + describe('when a user uses the wrong tree to declare', () => { + it('reverts', async () => { + const { mothership, satellite1, satellite2 } = chains; + await assertRevert( + mothership.CoreProxy.prepareBallotWithSnapshot( + mothership.SnapshotRecordMock.address, + ethers.Wallet.createRandom().address + ), + 'NoPower' + ); + await assertRevert( + satellite1.CoreProxy.prepareBallotWithSnapshot( + satellite1.SnapshotRecordMock.address, + ethers.Wallet.createRandom().address + ), + 'NoPower' + ); + await assertRevert( + satellite2.CoreProxy.prepareBallotWithSnapshot( + satellite2.SnapshotRecordMock.address, + ethers.Wallet.createRandom().address + ), + 'NoPower' + ); + }); + }); + }); + + describe('when users declare their cross chain debt shares correctly', () => { + before('declare', async () => { + const { mothership, satellite1, satellite2 } = chains; + await mothership.CoreProxy.prepareBallotWithSnapshot( + mothership.SnapshotRecordMock.address, + addresses[0].address + ); + await mothership.CoreProxy.prepareBallotWithSnapshot( + mothership.SnapshotRecordMock.address, + addresses[1].address + ); + // @dev: dont declare for addresses[2] + + await mothership.CoreProxy.prepareBallotWithSnapshot( + mothership.SnapshotRecordMock.address, + addresses[3].address + ); + await mothership.CoreProxy.prepareBallotWithSnapshot( + mothership.SnapshotRecordMock.address, + addresses[4].address + ); + + await satellite1.CoreProxy.prepareBallotWithSnapshot( + satellite1.SnapshotRecordMock.address, + addresses[0].address + ); + + await satellite1.CoreProxy.prepareBallotWithSnapshot( + satellite1.SnapshotRecordMock.address, + addresses[1].address + ); + + // @dev: dont declare for addresses[2] + + await satellite1.CoreProxy.prepareBallotWithSnapshot( + satellite1.SnapshotRecordMock.address, + addresses[3].address + ); + + await satellite1.CoreProxy.prepareBallotWithSnapshot( + satellite1.SnapshotRecordMock.address, + addresses[4].address + ); + + await satellite2.CoreProxy.prepareBallotWithSnapshot( + satellite2.SnapshotRecordMock.address, + addresses[0].address + ); + await satellite2.CoreProxy.prepareBallotWithSnapshot( + satellite2.SnapshotRecordMock.address, + addresses[1].address + ); + + // @dev: dont declare for addresses[2] + + await satellite2.CoreProxy.prepareBallotWithSnapshot( + satellite2.SnapshotRecordMock.address, + addresses[3].address + ); + + await satellite2.CoreProxy.prepareBallotWithSnapshot( + satellite2.SnapshotRecordMock.address, + addresses[4].address + ); + }); + + describe('when a user attempts to re-declare debt shares', () => { + it('reverts', async () => { + const { mothership, satellite1, satellite2 } = chains; + await assertRevert( + mothership.CoreProxy.prepareBallotWithSnapshot( + mothership.SnapshotRecordMock.address, + addresses[0].address + ), + 'BallotAlreadyPrepared' + ); + await assertRevert( + satellite1.CoreProxy.prepareBallotWithSnapshot( + satellite1.SnapshotRecordMock.address, + addresses[0].address + ), + 'BallotAlreadyPrepared' + ); + await assertRevert( + satellite2.CoreProxy.prepareBallotWithSnapshot( + satellite2.SnapshotRecordMock.address, + addresses[0].address + ), + 'BallotAlreadyPrepared' + ); + }); + }); + + describe('when users cast votes', () => { + before('vote', async () => { + const { mothership } = chains; + + await mothership.CoreProxy.connect( + addresses[0].connect(mothership.provider) + ).cast([epoch.winners()[0]], [ethers.utils.parseEther('100')]); + + await mothership.CoreProxy.connect( + addresses[1].connect(mothership.provider) + ).cast([epoch.winners()[0]], [ethers.utils.parseEther('100')]); + + // addresses[2] didn't declare cross chain debt shares yet + await assertRevert( + mothership.CoreProxy.connect(addresses[2].connect(mothership.provider)).cast( + [addresses[4].address], + [ethers.utils.parseEther('100')] + ), + 'NoVotingPower', + mothership.CoreProxy + ); + + await mothership.CoreProxy.connect( + addresses[3].connect(mothership.provider) + ).cast([addresses[1].address], [ethers.utils.parseEther('100')]); + + await mothership.CoreProxy.connect( + addresses[4].connect(mothership.provider) + ).cast([epoch.winners()[0]], [ethers.utils.parseEther('100')]); + }); + + it('do not allow partial voting', async () => { + const { mothership } = chains; + await assertRevert( + mothership.CoreProxy.connect(addresses[0].connect(mothership.provider)).cast( + [addresses[3].address], + [ethers.utils.parseEther('10')] + ), + 'InvalidBallot', + mothership.CoreProxy + ); + }); + + it('keeps track of which ballot each user voted on', async () => { + const { mothership } = chains; + + const ballotForFirstAddress = await mothership.CoreProxy.getBallot( + addresses[0].address, + (await mothership.provider.getNetwork()).chainId, + epoch.index + ); + + const ballotForSecondAddress = await mothership.CoreProxy.getBallot( + addresses[1].address, + (await mothership.provider.getNetwork()).chainId, + epoch.index + ); + + const ballotForThirdAddress = await mothership.CoreProxy.getBallot( + addresses[2].address, + (await mothership.provider.getNetwork()).chainId, + epoch.index + ); + + const ballotForFourthAddress = await mothership.CoreProxy.getBallot( + addresses[3].address, + (await mothership.provider.getNetwork()).chainId, + epoch.index + ); + + const ballotForFifthAddress = await mothership.CoreProxy.getBallot( + addresses[4].address, + (await mothership.provider.getNetwork()).chainId, + epoch.index + ); + + assert.deepEqual( + [ + ballotForFirstAddress.amounts, + ballotForFirstAddress.votedCandidates, + ballotForFirstAddress.votingPower, + ], + [ + [ethers.utils.parseEther('100')], + [epoch.winners()[0]], + ethers.utils.parseEther('100'), + ] + ); + + assert.deepEqual( + [ + ballotForSecondAddress.amounts, + ballotForSecondAddress.votedCandidates, + ballotForSecondAddress.votingPower, + ], + [ + [ethers.utils.parseEther('100')], + [epoch.winners()[0]], + ethers.utils.parseEther('100'), + ] + ); + + // should be empty + assert.deepEqual( + [ + ballotForThirdAddress.amounts, + ballotForThirdAddress.votedCandidates, + ballotForThirdAddress.votingPower, + ], + [[], [], ethers.utils.parseEther('0')] + ); + + assert.deepEqual( + [ + ballotForFourthAddress.amounts, + ballotForFourthAddress.votedCandidates, + ballotForFourthAddress.votingPower, + ], + [ + [ethers.utils.parseEther('100')], + [addresses[1].address], + ethers.utils.parseEther('100'), + ] + ); + + assert.deepEqual( + [ + ballotForFifthAddress.amounts, + ballotForFifthAddress.votedCandidates, + ballotForFifthAddress.votingPower, + ], + [ + [ethers.utils.parseEther('100')], + [epoch.winners()[0]], + ethers.utils.parseEther('100'), + ] + ); + }); + + it('keeps track of the candidates of each ballot', async () => { + const { mothership } = chains; + assert.deepEqual( + await mothership.CoreProxy.getBallotCandidates( + addresses[0].address, + 11155111, + epoch.index + ), + [epoch.winners()[0]] + ); + assert.deepEqual( + await mothership.CoreProxy.getBallotCandidates( + addresses[1].address, + 11155111, + epoch.index + ), + [epoch.winners()[0]] + ); + assert.deepEqual( + await mothership.CoreProxy.getBallotCandidates( + addresses[2].address, + 11155111, + epoch.index + ), + [] + ); + assert.deepEqual( + await mothership.CoreProxy.getBallotCandidates( + addresses[3].address, + 11155111, + epoch.index + ), + [addresses[1].address] + ); + assert.deepEqual( + await mothership.CoreProxy.getBallotCandidates( + addresses[4].address, + 11155111, + epoch.index + ), + [epoch.winners()[0]] + ); + }); + + describe('when voting ends', () => { + before('fast forward', async () => { + const { mothership, satellite1, satellite2 } = chains; + await fastForwardToEvaluationPeriod(mothership.provider); + await fastForwardToEvaluationPeriod(satellite1.provider); + await fastForwardToEvaluationPeriod(satellite2.provider); + }); + + it('shows that the current period is Evaluation', async () => { + const { mothership } = chains; + assertBn.equal( + await mothership.CoreProxy.getCurrentPeriod(), + ElectionPeriod.Evaluation + ); + }); + + describe('when the election is evaluated', () => { + let rx: ethers.ContractReceipt; + + before('evaluate', async () => { + rx = await (await chains.mothership.CoreProxy.evaluate(0)).wait(); + }); + + it('emits the event ElectionEvaluated', async () => { + await assertEvent( + rx, + `ElectionEvaluated(${epoch.index}, 4)`, + chains.mothership.CoreProxy + ); + }); + + it('shows that the election is evaluated', async () => { + assert.equal(await chains.mothership.CoreProxy.isElectionEvaluated(), true); + }); + + it('shows the election winners', async () => { + const { mothership } = chains; + assert.deepEqual( + await mothership.CoreProxy.getElectionWinners(), + epoch.winners() + ); + }); + + describe('when the election is resolved', () => { + before('resolve', async () => { + const { mothership, satellite1, satellite2 } = chains; + const rx = await ( + await mothership.CoreProxy.resolve({ + value: ethers.utils.parseEther('1'), + }) + ).wait(); + + await ccipReceive({ + rx, + ccipAddress: satellite1.CcipRouter.address, + sourceChainSelector: ChainSelector.mothership, + targetSigner: satellite1.signer, + index: 0, + }); + + await ccipReceive({ + rx, + ccipAddress: satellite2.CcipRouter.address, + sourceChainSelector: ChainSelector.mothership, + targetSigner: satellite2.signer, + index: 1, + }); + }); + + it('shows the expected NFT owners', async () => { + const { mothership } = chains; + + assertBn.equal( + await mothership.CouncilToken.balanceOf(epoch.winners()[0]), + 1 + ); + }); + }); + }); + }); + }); + }); + }); + }); + }); + }); + }); +}); diff --git a/protocol/governance/test/integration/Evaluation.test.ts b/protocol/governance/test/integration/Evaluation.test.ts new file mode 100644 index 0000000000..99601921ac --- /dev/null +++ b/protocol/governance/test/integration/Evaluation.test.ts @@ -0,0 +1,43 @@ +import { fastForwardTo } from '@synthetixio/core-utils/utils/hardhat/rpc'; +import { integrationBootstrap } from './bootstrap'; +import { ethers } from 'ethers'; +import assert from 'node:assert/strict'; +import { ccipReceive } from '@synthetixio/core-modules/test/helpers/ccip'; + +describe('Evaluation', function () { + const { chains } = integrationBootstrap(); + + const fastForwardToEvaluationPeriod = async (provider: ethers.providers.JsonRpcProvider) => { + const schedule = await chains.mothership.CoreProxy.getEpochSchedule(); + await fastForwardTo(schedule.endDate.add(10).toNumber(), provider); + }; + describe('no nominees', () => { + it('should jump to nomination period', async () => { + const { mothership, satellite1, satellite2 } = chains; + + assert.deepEqual(await mothership.CoreProxy.getNominees(), []); + await fastForwardToEvaluationPeriod(mothership.provider); + assert.equal((await mothership.CoreProxy.getCurrentPeriod()).toNumber(), 3); + + const rx = await (await mothership.CoreProxy.evaluate(0)).wait(); + + await ccipReceive({ + ccipAddress: satellite1.CcipRouter.address, + rx, + sourceChainSelector: mothership.chainSlector, + targetSigner: satellite1.signer, + index: 0, + }); + + await ccipReceive({ + ccipAddress: satellite2.CcipRouter.address, + rx, + sourceChainSelector: mothership.chainSlector, + targetSigner: satellite2.signer, + index: 1, + }); + + assert.equal((await mothership.CoreProxy.getCurrentPeriod()).toNumber(), 2); + }); + }); +}); diff --git a/protocol/governance/test/integration/bootstrap.ts b/protocol/governance/test/integration/bootstrap.ts new file mode 100644 index 0000000000..bc518b6516 --- /dev/null +++ b/protocol/governance/test/integration/bootstrap.ts @@ -0,0 +1,140 @@ +import path from 'node:path'; +import { ccipReceive } from '@synthetixio/core-modules/test/helpers/ccip'; +import { fastForwardTo } from '@synthetixio/core-utils/utils/hardhat/rpc'; +import { ethers } from 'ethers'; +import hre from 'hardhat'; +import { typedValues } from '../helpers/object'; +import { spinChain } from '../helpers/spin-chain'; + +import type { CoreProxy as SepoliaCoreProxy } from '../generated/typechain/sepolia'; +import type { CoreProxy as OptimisticGoerliCoreProxy } from '../generated/typechain/optimistic-goerli'; +import type { CoreProxy as AvalancheFujiCoreProxy } from '../generated/typechain/avalanche-fuji'; + +interface Proxies { + mothership: SepoliaCoreProxy; + satellite1: OptimisticGoerliCoreProxy; + satellite2: AvalancheFujiCoreProxy; +} + +export enum ChainSelector { + mothership = '16015286601757825753', + satellite1 = '2664363617261496610', + satellite2 = '14767482510784806043', +} + +export interface SignerOnChains { + mothership: ethers.Signer; + satellite1: ethers.Signer; + satellite2: ethers.Signer; +} + +export type Chain = Awaited>>; + +export interface Chains { + mothership: Chain; + satellite1: Chain; + satellite2: Chain; +} + +const chains: Chains = {} as unknown as Chains; + +let snapshotsIds: string[] = []; +async function createSnapshots() { + snapshotsIds = await Promise.all( + typedValues(chains).map((c) => c.provider.send('evm_snapshot', [])) + ); +} + +async function restoreSnapshots() { + await Promise.all( + typedValues(chains).map((c, i) => c.provider.send('evm_revert', [snapshotsIds[i]])) + ); + await createSnapshots(); +} + +async function fixtureSignerOnChains() { + const { address, privateKey } = ethers.Wallet.createRandom(); + + const signers = await Promise.all( + typedValues(chains).map(async (chain) => { + await chain.provider.send('hardhat_setBalance', [address, `0x${(1e22).toString(16)}`]); + return new ethers.Wallet(privateKey, chain.provider); + }) + ); + + return { + mothership: signers[0], + satellite1: signers[1], + satellite2: signers[2], + } satisfies SignerOnChains; +} + +async function fastForwardChainsTo(timestamp: number) { + return await Promise.all( + Object.values(chains).map((chain) => fastForwardTo(timestamp, chain.provider)) + ); +} + +before(`setup integration chains`, async function () { + this.timeout(90000); + + const generatedPath = path.resolve(hre.config.paths.tests, 'generated'); + const typechainFolder = path.resolve(generatedPath, 'typechain'); + const writeDeployments = path.resolve(generatedPath, 'deployments'); + + /// @dev: show build logs with DEBUG=spawn:* + // TODO: When running in parallel there's an unknown error that causes to some + // builds to finish early without throwing error but they do not complete. + const [mothership, satellite1, satellite2] = await Promise.all([ + spinChain({ + networkName: 'sepolia', + cannonfile: 'cannonfile.test.toml', + typechainFolder, + writeDeployments, + chainSlector: ChainSelector.mothership, + }), + spinChain({ + networkName: 'optimistic-goerli', + cannonfile: 'cannonfile.satellite.test.toml', + typechainFolder, + writeDeployments, + chainSlector: ChainSelector.satellite1, + }), + spinChain({ + networkName: 'avalanche-fuji', + cannonfile: 'cannonfile.satellite.test.toml', + typechainFolder, + writeDeployments, + chainSlector: ChainSelector.satellite2, + }), + ]); + + Object.assign(chains, { + mothership, + satellite1, + satellite2, + } satisfies Chains); +}); + +before('setup election cross chain state', async () => { + const { mothership, ...satellites } = chains; + + for (const satellite of Object.values(satellites)) { + const tx = await mothership.CoreProxy.initElectionModuleSatellite(satellite.chainId); + const rx = await tx.wait(); + + await ccipReceive({ + rx, + sourceChainSelector: mothership.chainSlector, + targetSigner: satellite.signer, + ccipAddress: mothership.CcipRouter.address, + }); + } +}); + +before('snapshot checkpoint', createSnapshots); + +export function integrationBootstrap() { + before('back to snapshot', restoreSnapshots); + return { chains, fixtureSignerOnChains, fastForwardChainsTo }; +} diff --git a/protocol/governance/tomls/ccip.test.toml b/protocol/governance/tomls/ccip.test.toml new file mode 100644 index 0000000000..2dc11fb813 --- /dev/null +++ b/protocol/governance/tomls/ccip.test.toml @@ -0,0 +1,32 @@ +# (required) CCIP Router address (should be one of https://docs.chain.link/ccip/supported-networks/) +# [setting.ccip_router] +# defaultValue = "0x261c05167db67B2b619f9d312e0753f3721ad6E8" +# +[setting.ccip_mothership_chain_id] +defaultValue = "11155111" + +[setting.ccip_mothership_selector] +defaultValue = "16015286601757825753" + +[contract.CcipRouterMock] +artifact = "contracts/mocks/CcipRouterMock.sol:CcipRouterMock" +create2 = true + +[invoke.set_supported_cross_chain_networks] +target = ["CoreProxy"] +from = "<%= settings.owner %>" +func = "setSupportedCrossChainNetworks" +args = [ + ["<%= settings.ccip_mothership_chain_id %>", "420", "43113"], + [ + "<%= settings.ccip_mothership_selector %>", + "2664363617261496610", + "14767482510784806043" + ] +] + +[invoke.configure_chainlink_cross_chain] +target = ["CoreProxy"] +func = "configureChainlinkCrossChain" +from = "<%= settings.owner %>" +args = ["<%= contracts.CcipRouterMock.address %>"] diff --git a/protocol/governance/tomls/council-token.toml b/protocol/governance/tomls/council-token.toml new file mode 100644 index 0000000000..a5a2ed78f4 --- /dev/null +++ b/protocol/governance/tomls/council-token.toml @@ -0,0 +1,29 @@ +[setting.council_token_name] +defaultValue = "Synthetix Governance Module" + +[setting.council_token_symbol] +defaultValue = "SNXGOV" + +[setting.council_token_uri] +defaultValue = "https://synthetix.io" + +[contract.CouncilTokenModule] +artifact = "contracts/modules/council-nft/CouncilTokenModule.sol:CouncilTokenModule" + +[router.CouncilTokenRouter] +contracts = ["CouncilTokenModule", "InitialModuleBundle"] + +[invoke.init_council_token] +target = ["CoreProxy"] +from = "<%= settings.owner %>" +func = "initOrUpgradeNft" +args = [ + "<%= formatBytes32String('councilToken') %>", + "<%= settings.council_token_name %>", + "<%= settings.council_token_symbol %>", + "<%= settings.council_token_uri %>", + "<%= contracts.CouncilTokenRouter.address %>" +] +factory.CouncilToken.abiOf = ["CouncilTokenRouter"] +factory.CouncilToken.event = "AssociatedSystemSet" +factory.CouncilToken.arg = 2 diff --git a/protocol/governance/tomls/proxy-base.toml b/protocol/governance/tomls/proxy-base.toml new file mode 100644 index 0000000000..1805c92de0 --- /dev/null +++ b/protocol/governance/tomls/proxy-base.toml @@ -0,0 +1,20 @@ +[setting.salt] +defaultValue = "governance" + +# Deployment Owner, defaults to first hardhat account +[setting.owner] +defaultValue = "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266" + +[contract.InitialModuleBundle] +artifact = "contracts/modules/core/InitialModuleBundle.sol:InitialModuleBundle" +create2 = true + +[contract.InitialProxy] +artifact = "contracts/Proxy.sol:Proxy" +args = ["<%= contracts.InitialModuleBundle.address %>", "<%= settings.owner %>"] +salt = "<%= settings.salt %>" +abiOf = ["InitialModuleBundle"] +create2 = true + +[provision.trusted_multicall_forwarder] +source = "trusted-multicall-forwarder" diff --git a/protocol/synthetix/cannonfile.test.toml b/protocol/synthetix/cannonfile.test.toml index 080d24e1a1..1404f06772 100644 --- a/protocol/synthetix/cannonfile.test.toml +++ b/protocol/synthetix/cannonfile.test.toml @@ -22,9 +22,6 @@ artifact = "contracts/generated/test/TestableCollateralConfigurationStorage.sol: [contract.TestableCollateralLockStorage] artifact = "contracts/generated/test/TestableCollateralLockStorage.sol:TestableCollateralLockStorage" -[contract.TestableCrossChainStorage] -artifact = "contracts/generated/test/TestableCrossChainStorage.sol:TestableCrossChainStorage" - [contract.TestableDistributionStorage] artifact = "contracts/generated/test/TestableDistributionStorage.sol:TestableDistributionStorage" @@ -72,6 +69,7 @@ contracts = [ "CcipReceiverModule", "CollateralModule", "CollateralConfigurationModule", + "CrossChainModule", "CrossChainUSDModule", "IssueUSDModule", "LiquidationModule", @@ -87,7 +85,6 @@ contracts = [ "TestableCollateralStorage", "TestableCollateralConfigurationStorage", "TestableCollateralLockStorage", - "TestableCrossChainStorage", "TestableDistributionStorage", "TestableDistributionActorStorage", "TestableMarketStorage", diff --git a/protocol/synthetix/cannonfile.toml b/protocol/synthetix/cannonfile.toml index 85b4e62c9e..10cde882c7 100644 --- a/protocol/synthetix/cannonfile.toml +++ b/protocol/synthetix/cannonfile.toml @@ -49,6 +49,9 @@ artifact = "contracts/modules/core/CollateralModule.sol:CollateralModule" [contract.CollateralConfigurationModule] artifact = "contracts/modules/core/CollateralConfigurationModule.sol:CollateralConfigurationModule" +[contract.CrossChainModule] +artifact = "contracts/modules/core/CrossChainModule.sol:CrossChainModule" + [contract.CrossChainUSDModule] artifact = "contracts/modules/core/CrossChainUSDModule.sol:CrossChainUSDModule" @@ -96,6 +99,7 @@ contracts = [ "CcipReceiverModule", "CollateralModule", "CollateralConfigurationModule", + "CrossChainModule", "CrossChainUSDModule", "IssueUSDModule", "LiquidationModule", diff --git a/protocol/synthetix/contracts/interfaces/IUtilsModule.sol b/protocol/synthetix/contracts/interfaces/IUtilsModule.sol index f39f29c69f..0235c5c072 100644 --- a/protocol/synthetix/contracts/interfaces/IUtilsModule.sol +++ b/protocol/synthetix/contracts/interfaces/IUtilsModule.sol @@ -7,31 +7,6 @@ import {IERC165} from "@synthetixio/core-contracts/contracts/interfaces/IERC165. * @title Module with assorted utility functions. */ interface IUtilsModule is IERC165 { - /** - * @notice Emitted when a new cross chain network becomes supported by the protocol - */ - event NewSupportedCrossChainNetwork(uint64 newChainId); - - /** - * @notice Configure CCIP addresses on the stablecoin. - * @param ccipRouter The address on this chain to which CCIP messages will be sent or received. - * @param ccipTokenPool The address where CCIP fees will be sent to when sending and receiving cross chain messages. - */ - function configureChainlinkCrossChain(address ccipRouter, address ccipTokenPool) external; - - /** - * @notice Used to add new cross chain networks to the protocol - * Ignores a network if it matches the current chain id - * Ignores a network if it has already been added - * @param supportedNetworks array of all networks that are supported by the protocol - * @param ccipSelectors the ccip "selector" which maps to the chain id on the same index. must be same length as `supportedNetworks` - * @return numRegistered the number of networks that were actually registered - */ - function setSupportedCrossChainNetworks( - uint64[] memory supportedNetworks, - uint64[] memory ccipSelectors - ) external returns (uint256 numRegistered); - /** * @notice Configure the system's single oracle manager address. * @param oracleManagerAddress The address of the oracle manager. @@ -66,6 +41,13 @@ interface IUtilsModule is IERC165 { */ function getConfigAddress(bytes32 k) external view returns (address v); + /** + * @notice Configure CCIP addresses on the stablecoin. + * @param ccipRouter The address on this chain to which CCIP messages will be sent or received. + * @param ccipTokenPool The address where CCIP fees will be sent to when sending and receiving cross chain messages. + */ + function configureUsdTokenChainlink(address ccipRouter, address ccipTokenPool) external; + /** * @notice Checks if the address is the trusted forwarder * @param forwarder The address to check diff --git a/protocol/synthetix/contracts/mocks/CcipRouterMock.sol b/protocol/synthetix/contracts/mocks/CcipRouterMock.sol index e42d2de5d2..def0d71603 100644 --- a/protocol/synthetix/contracts/mocks/CcipRouterMock.sol +++ b/protocol/synthetix/contracts/mocks/CcipRouterMock.sol @@ -1,7 +1,6 @@ //SPDX-License-Identifier: MIT pragma solidity >=0.8.4; - -import "../interfaces/external/ICcipRouterClient.sol"; +import "@synthetixio/core-modules/contracts/interfaces/external/ICcipRouterClient.sol"; contract CcipRouterMock { // solhint-disable no-empty-blocks diff --git a/protocol/synthetix/contracts/modules/core/CcipReceiverModule.sol b/protocol/synthetix/contracts/modules/core/CcipReceiverModule.sol index 7126a304fa..f2ed096950 100644 --- a/protocol/synthetix/contracts/modules/core/CcipReceiverModule.sol +++ b/protocol/synthetix/contracts/modules/core/CcipReceiverModule.sol @@ -1,22 +1,12 @@ //SPDX-License-Identifier: MIT pragma solidity >=0.8.11 <0.9.0; -import "@synthetixio/core-modules/contracts/interfaces/IAssociatedSystemsModule.sol"; -import "@synthetixio/core-modules/contracts/storage/AssociatedSystem.sol"; -import "@synthetixio/core-contracts/contracts/ownership/OwnableStorage.sol"; - -import "../../interfaces/external/IAny2EVMMessageReceiver.sol"; - -import "../../storage/OracleManager.sol"; -import "../../storage/Config.sol"; -import "../../storage/CrossChain.sol"; +import {CcipReceiverModule as BaseCcipReceiverModule} from "@synthetixio/core-modules/contracts/modules/CcipReceiverModule.sol"; /** - * @title Module with assorted utility functions. - * @dev See IUtilsModule. + * @title Module that handles receiving ccip messages. */ -contract CcipReceiverModule is IAny2EVMMessageReceiver { - function ccipReceive(CcipClient.Any2EVMMessage memory message) external { - CrossChain.processCcipReceive(CrossChain.load(), message); - } +// solhint-disable-next-line no-empty-blocks +contract CcipReceiverModule is BaseCcipReceiverModule { + } diff --git a/protocol/synthetix/contracts/modules/core/CrossChainModule.sol b/protocol/synthetix/contracts/modules/core/CrossChainModule.sol new file mode 100644 index 0000000000..df36c8c708 --- /dev/null +++ b/protocol/synthetix/contracts/modules/core/CrossChainModule.sol @@ -0,0 +1,12 @@ +//SPDX-License-Identifier: MIT +pragma solidity >=0.8.11 <0.9.0; + +import {CrossChainModule as BaseCrossChainModule} from "@synthetixio/core-modules/contracts/modules/CrossChainModule.sol"; + +/** + * @title Module that handles anything related to cross-chain. + */ +// solhint-disable-next-line no-empty-blocks +contract CrossChainModule is BaseCrossChainModule { + +} diff --git a/protocol/synthetix/contracts/modules/core/CrossChainUSDModule.sol b/protocol/synthetix/contracts/modules/core/CrossChainUSDModule.sol index 1883920d3c..2732bedb5f 100644 --- a/protocol/synthetix/contracts/modules/core/CrossChainUSDModule.sol +++ b/protocol/synthetix/contracts/modules/core/CrossChainUSDModule.sol @@ -5,10 +5,9 @@ import "../../interfaces/ICrossChainUSDModule.sol"; import "@synthetixio/core-modules/contracts/interfaces/ITokenModule.sol"; import "@synthetixio/core-contracts/contracts/utils/ERC2771Context.sol"; -import "../../storage/CrossChain.sol"; - import "@synthetixio/core-modules/contracts/storage/AssociatedSystem.sol"; import "@synthetixio/core-modules/contracts/storage/FeatureFlag.sol"; +import "@synthetixio/core-modules/contracts/storage/CrossChain.sol"; /** * @title Module for the cross-chain transfers of stablecoins. diff --git a/protocol/synthetix/contracts/modules/core/UtilsModule.sol b/protocol/synthetix/contracts/modules/core/UtilsModule.sol index faeeca7102..fc8e751e22 100644 --- a/protocol/synthetix/contracts/modules/core/UtilsModule.sol +++ b/protocol/synthetix/contracts/modules/core/UtilsModule.sol @@ -4,86 +4,25 @@ pragma solidity >=0.8.11 <0.9.0; import "@synthetixio/core-modules/contracts/interfaces/IAssociatedSystemsModule.sol"; import "@synthetixio/core-modules/contracts/storage/AssociatedSystem.sol"; import "@synthetixio/core-contracts/contracts/ownership/OwnableStorage.sol"; -import "@synthetixio/core-contracts/contracts/errors/ParameterError.sol"; -import "@synthetixio/core-contracts/contracts/utils/SafeCast.sol"; import "@synthetixio/core-contracts/contracts/utils/ERC2771Context.sol"; import "../../interfaces/IUtilsModule.sol"; -import "../../storage/CrossChain.sol"; import "../../storage/OracleManager.sol"; import "../../storage/Config.sol"; -import "../../interfaces/external/IAny2EVMMessageReceiver.sol"; - /** * @title Module with assorted utility functions. * @dev See IUtilsModule. */ contract UtilsModule is IUtilsModule { using AssociatedSystem for AssociatedSystem.Data; - using SetUtil for SetUtil.UintSet; - using SafeCastU256 for uint256; bytes32 private constant _USD_TOKEN = "USDToken"; bytes32 private constant _CCIP_CHAINLINK_SEND = "ccipChainlinkSend"; bytes32 private constant _CCIP_CHAINLINK_RECV = "ccipChainlinkRecv"; bytes32 private constant _CCIP_CHAINLINK_TOKEN_POOL = "ccipChainlinkTokenPool"; - /** - * @inheritdoc IUtilsModule - */ - function configureChainlinkCrossChain( - address ccipRouter, - address ccipTokenPool - ) external override { - OwnableStorage.onlyOwner(); - - CrossChain.Data storage cc = CrossChain.load(); - - cc.ccipRouter = ICcipRouterClient(ccipRouter); - - IAssociatedSystemsModule usdToken = IAssociatedSystemsModule( - AssociatedSystem.load(_USD_TOKEN).proxy - ); - - usdToken.registerUnmanagedSystem(_CCIP_CHAINLINK_SEND, ccipRouter); - usdToken.registerUnmanagedSystem(_CCIP_CHAINLINK_RECV, ccipRouter); - usdToken.registerUnmanagedSystem(_CCIP_CHAINLINK_TOKEN_POOL, ccipTokenPool); - } - - /** - * @inheritdoc IUtilsModule - */ - function setSupportedCrossChainNetworks( - uint64[] memory supportedNetworks, - uint64[] memory ccipSelectors - ) external returns (uint256 numRegistered) { - OwnableStorage.onlyOwner(); - - uint64 myChainId = block.chainid.to64(); - - if (ccipSelectors.length != supportedNetworks.length) { - revert ParameterError.InvalidParameter("ccipSelectors", "must match length"); - } - - CrossChain.Data storage cc = CrossChain.load(); - for (uint i = 0; i < supportedNetworks.length; i++) { - if (supportedNetworks[i] == myChainId) continue; - if ( - supportedNetworks[i] != myChainId && - !cc.supportedNetworks.contains(supportedNetworks[i]) - ) { - numRegistered++; - cc.supportedNetworks.add(supportedNetworks[i]); - emit NewSupportedCrossChainNetwork(supportedNetworks[i]); - } - - cc.ccipChainIdToSelector[supportedNetworks[i]] = ccipSelectors[i]; - cc.ccipSelectorToChainId[ccipSelectors[i]] = supportedNetworks[i]; - } - } - /** * @inheritdoc IUtilsModule */ @@ -128,8 +67,21 @@ contract UtilsModule is IUtilsModule { function supportsInterface( bytes4 interfaceId ) public view virtual override(IERC165) returns (bool) { - return - interfaceId == type(IAny2EVMMessageReceiver).interfaceId || - interfaceId == this.supportsInterface.selector; + return interfaceId == this.supportsInterface.selector; + } + + function configureUsdTokenChainlink( + address ccipRouter, + address ccipTokenPool + ) external override { + OwnableStorage.onlyOwner(); + + IAssociatedSystemsModule usdToken = IAssociatedSystemsModule( + AssociatedSystem.load(_USD_TOKEN).getAddress() + ); + + usdToken.registerUnmanagedSystem(_CCIP_CHAINLINK_SEND, ccipRouter); + usdToken.registerUnmanagedSystem(_CCIP_CHAINLINK_RECV, ccipRouter); + usdToken.registerUnmanagedSystem(_CCIP_CHAINLINK_TOKEN_POOL, ccipTokenPool); } } diff --git a/protocol/synthetix/contracts/modules/usd/USDTokenModule.sol b/protocol/synthetix/contracts/modules/usd/USDTokenModule.sol index 25303914e6..e6be5d5614 100644 --- a/protocol/synthetix/contracts/modules/usd/USDTokenModule.sol +++ b/protocol/synthetix/contracts/modules/usd/USDTokenModule.sol @@ -2,10 +2,10 @@ pragma solidity ^0.8.7; import "../../interfaces/IUSDTokenModule.sol"; -import "../../storage/CrossChain.sol"; import "@synthetixio/core-contracts/contracts/utils/ERC2771Context.sol"; import "@synthetixio/core-modules/contracts/storage/AssociatedSystem.sol"; +import "@synthetixio/core-modules/contracts/storage/CrossChain.sol"; import "@synthetixio/core-contracts/contracts/token/ERC20.sol"; import "@synthetixio/core-modules/contracts/storage/FeatureFlag.sol"; import "@synthetixio/core-contracts/contracts/initializable/InitializableMixin.sol"; diff --git a/protocol/synthetix/storage.dump.sol b/protocol/synthetix/storage.dump.sol index fae28b4090..266c3a1120 100644 --- a/protocol/synthetix/storage.dump.sol +++ b/protocol/synthetix/storage.dump.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.4; +pragma solidity >=0.8.11<0.9.0; // @custom:artifact @synthetixio/core-contracts/contracts/ownership/OwnableStorage.sol:OwnableStorage library OwnableStorage { @@ -155,6 +155,23 @@ library AssociatedSystem { } } +// @custom:artifact @synthetixio/core-modules/contracts/storage/CrossChain.sol:CrossChain +library CrossChain { + bytes32 private constant _SLOT_CROSS_CHAIN = keccak256(abi.encode("io.synthetix.core-modules.CrossChain")); + struct Data { + address ccipRouter; + SetUtil.UintSet supportedNetworks; + mapping(uint64 => uint64) ccipChainIdToSelector; + mapping(uint64 => uint64) ccipSelectorToChainId; + } + function load() internal pure returns (Data storage crossChain) { + bytes32 s = _SLOT_CROSS_CHAIN; + assembly { + crossChain.slot := s + } + } +} + // @custom:artifact @synthetixio/core-modules/contracts/storage/FeatureFlag.sol:FeatureFlag library FeatureFlag { struct Data { @@ -185,6 +202,33 @@ library Initialized { } } +// @custom:artifact @synthetixio/core-modules/contracts/utils/CcipClient.sol:CcipClient +library CcipClient { + bytes4 public constant EVM_EXTRA_ARGS_V1_TAG = 0x97a657c9; + struct EVMTokenAmount { + address token; + uint256 amount; + } + struct Any2EVMMessage { + bytes32 messageId; + uint64 sourceChainSelector; + bytes sender; + bytes data; + EVMTokenAmount[] tokenAmounts; + } + struct EVM2AnyMessage { + bytes receiver; + bytes data; + EVMTokenAmount[] tokenAmounts; + address feeToken; + bytes extraArgs; + } + struct EVMExtraArgsV1 { + uint256 gasLimit; + bool strict; + } +} + // @custom:artifact @synthetixio/oracle-manager/contracts/interfaces/external/IPyth.sol:PythStructs contract PythStructs { struct Price { @@ -474,23 +518,6 @@ library Config { } } -// @custom:artifact contracts/storage/CrossChain.sol:CrossChain -library CrossChain { - bytes32 private constant _SLOT_CROSS_CHAIN = keccak256(abi.encode("io.synthetix.synthetix.CrossChain")); - struct Data { - address ccipRouter; - SetUtil.UintSet supportedNetworks; - mapping(uint64 => uint64) ccipChainIdToSelector; - mapping(uint64 => uint64) ccipSelectorToChainId; - } - function load() internal pure returns (Data storage crossChain) { - bytes32 s = _SLOT_CROSS_CHAIN; - assembly { - crossChain.slot := s - } - } -} - // @custom:artifact contracts/storage/Distribution.sol:Distribution library Distribution { struct Data { @@ -709,30 +736,3 @@ library VaultEpoch { mapping(uint128 => uint64) lastDelegationTime; } } - -// @custom:artifact contracts/utils/CcipClient.sol:CcipClient -library CcipClient { - bytes4 public constant EVM_EXTRA_ARGS_V1_TAG = 0x97a657c9; - struct EVMTokenAmount { - address token; - uint256 amount; - } - struct Any2EVMMessage { - bytes32 messageId; - uint64 sourceChainSelector; - bytes sender; - bytes data; - EVMTokenAmount[] tokenAmounts; - } - struct EVM2AnyMessage { - bytes receiver; - bytes data; - EVMTokenAmount[] tokenAmounts; - address feeToken; - bytes extraArgs; - } - struct EVMExtraArgsV1 { - uint256 gasLimit; - bool strict; - } -} diff --git a/protocol/synthetix/test/integration/modules/core/CcipReceiverModule.test.ts b/protocol/synthetix/test/integration/modules/core/CcipReceiverModule.test.ts index edaf89d2f8..a830012c20 100644 --- a/protocol/synthetix/test/integration/modules/core/CcipReceiverModule.test.ts +++ b/protocol/synthetix/test/integration/modules/core/CcipReceiverModule.test.ts @@ -2,7 +2,6 @@ import assertBn from '@synthetixio/core-utils/utils/assertions/assert-bignumber' import assertEvent from '@synthetixio/core-utils/utils/assertions/assert-event'; import assertRevert from '@synthetixio/core-utils/utils/assertions/assert-revert'; import { ethers } from 'ethers'; - import { bn, bootstrapWithStakedPool } from '../../bootstrap'; describe('CcipReceiverModule', function () { @@ -24,7 +23,11 @@ describe('CcipReceiverModule', function () { before('set ccip settings', async () => { await systems() .Core.connect(owner()) - .configureChainlinkCrossChain(await FakeCcip.getAddress(), ethers.constants.AddressZero); + .configureChainlinkCrossChain(await FakeCcip.getAddress()); + + await systems() + .Core.connect(owner()) + .configureUsdTokenChainlink(await FakeCcip.getAddress(), ethers.constants.AddressZero); await systems() .Core.connect(owner()) diff --git a/protocol/synthetix/test/integration/modules/core/CrossChainUSDModule.test.ts b/protocol/synthetix/test/integration/modules/core/CrossChainUSDModule.test.ts index 17abd4b2da..7e46a8e5cb 100644 --- a/protocol/synthetix/test/integration/modules/core/CrossChainUSDModule.test.ts +++ b/protocol/synthetix/test/integration/modules/core/CrossChainUSDModule.test.ts @@ -1,10 +1,10 @@ import assertBn from '@synthetixio/core-utils/src/utils/assertions/assert-bignumber'; import assertEvent from '@synthetixio/core-utils/utils/assertions/assert-event'; import assertRevert from '@synthetixio/core-utils/utils/assertions/assert-revert'; -import hre from 'hardhat'; import { ethers } from 'ethers'; -import { verifyUsesFeatureFlag } from '../../verifications'; +import hre from 'hardhat'; import { bn, bootstrapWithStakedPool } from '../../bootstrap'; +import { verifyUsesFeatureFlag } from '../../verifications'; describe('CrossChainUSDModule', function () { const { owner, systems, staker, accountId, poolId, collateralAddress } = @@ -27,9 +27,11 @@ describe('CrossChainUSDModule', function () { }); before('configure CCIP', async () => { + await systems().Core.connect(owner()).configureChainlinkCrossChain(CcipRouterMock.address); + await systems() .Core.connect(owner()) - .configureChainlinkCrossChain(CcipRouterMock.address, ethers.constants.AddressZero); + .configureUsdTokenChainlink(CcipRouterMock.address, ethers.constants.AddressZero); }); before('mint some sUSD', async () => { diff --git a/protocol/synthetix/test/integration/modules/core/USDTokenModule.test.ts b/protocol/synthetix/test/integration/modules/core/USDTokenModule.test.ts index 9eafa928e2..4e6d7c9756 100644 --- a/protocol/synthetix/test/integration/modules/core/USDTokenModule.test.ts +++ b/protocol/synthetix/test/integration/modules/core/USDTokenModule.test.ts @@ -43,7 +43,11 @@ describe('USDTokenModule', function () { describe('burn(uint256)', () => { before('configure CCIP', async () => { - await systems().Core.connect(owner()).configureChainlinkCrossChain( + await systems() + .Core.connect(owner()) + .configureChainlinkCrossChain(ethers.constants.AddressZero); + + await systems().Core.connect(owner()).configureUsdTokenChainlink( ethers.constants.AddressZero, stakerAddress // fake CCIP token pool address ); diff --git a/protocol/synthetix/test/integration/modules/core/UtilsModule.test.ts b/protocol/synthetix/test/integration/modules/core/UtilsModule.test.ts index 84c5e09105..18e07342a5 100644 --- a/protocol/synthetix/test/integration/modules/core/UtilsModule.test.ts +++ b/protocol/synthetix/test/integration/modules/core/UtilsModule.test.ts @@ -12,53 +12,6 @@ describe('UtilsModule', function () { [owner, user1] = signers(); }); - describe('registerCcip()', () => { - it('is only owner', async () => { - await assertRevert( - systems() - .Core.connect(user1) - .configureChainlinkCrossChain(ethers.constants.AddressZero, ethers.constants.AddressZero), - `Unauthorized("${await user1.getAddress()}")`, - systems().Core - ); - }); - - describe('on success', () => { - before('call', async () => { - await systems() - .Core.connect(owner) - .configureChainlinkCrossChain(user1.getAddress(), user1.getAddress()); - }); - - it('sets ccip values in usd token', async () => { - assert.equal( - ( - await systems().USD.getAssociatedSystem( - ethers.utils.formatBytes32String('ccipChainlinkSend') - ) - )[0], - await user1.getAddress() - ); - assert.equal( - ( - await systems().USD.getAssociatedSystem( - ethers.utils.formatBytes32String('ccipChainlinkRecv') - ) - )[0], - await user1.getAddress() - ); - assert.equal( - ( - await systems().USD.getAssociatedSystem( - ethers.utils.formatBytes32String('ccipChainlinkTokenPool') - ) - )[0], - await user1.getAddress() - ); - }); - }); - }); - describe('configureOracleManager()', () => { it('is only owner', async () => { await assertRevert( diff --git a/utils/common-config/hardhat.config.ts b/utils/common-config/hardhat.config.ts index 5127688a05..1532c743a4 100644 --- a/utils/common-config/hardhat.config.ts +++ b/utils/common-config/hardhat.config.ts @@ -88,6 +88,13 @@ const config = { accounts: process.env.DEPLOYER_PRIVATE_KEY ? [process.env.DEPLOYER_PRIVATE_KEY] : [], chainId: 43113, }, + sepolia: { + url: + process.env.NETWORK_ENDPOINT || + `https://sepolia.infura.io/v3/${process.env.INFURA_API_KEY}`, + accounts: process.env.DEPLOYER_PRIVATE_KEY ? [process.env.DEPLOYER_PRIVATE_KEY] : [], + chainId: 11155111, + }, }, gasReporter: { enabled: !!process.env.REPORT_GAS, diff --git a/utils/core-modules/cannonfile.test.toml b/utils/core-modules/cannonfile.test.toml index 5257dbb004..9fb916e677 100644 --- a/utils/core-modules/cannonfile.test.toml +++ b/utils/core-modules/cannonfile.test.toml @@ -24,6 +24,9 @@ artifact = "contracts/modules/CoreModule.sol:CoreModule" [contract.AssociatedSystemsModule] artifact = "contracts/modules/AssociatedSystemsModule.sol:AssociatedSystemsModule" +[contract.CrossChainModule] +artifact = "contracts/modules/CrossChainModule.sol:CrossChainModule" + [contract.FeatureFlagModule] artifact = "contracts/modules/FeatureFlagModule.sol:FeatureFlagModule" @@ -46,6 +49,9 @@ artifact = "contracts/modules/mocks/SampleOwnedModule.sol:SampleOwnedModule" [contract.GenericModule] artifact = "contracts/modules/mocks/GenericModule.sol:GenericModule" +[contract.CcipRouterMock] +artifact = "contracts/mocks/CcipRouterMock.sol:CcipRouterMock" + # Setup Initial Proxy [contract.Proxy] artifact = "contracts/Proxy.sol:Proxy" @@ -93,6 +99,8 @@ salt = "second" contracts = ["CoreModule", "NftModule"] salt = "third" -# FeatureFlagModule Router [router.FeatureFlagModuleRouter] contracts = ["CoreModule", "FeatureFlagModule", "SampleFeatureFlagModule"] + +[router.CrossChainModuleRouter] +contracts = ["CoreModule", "CrossChainModule"] diff --git a/utils/core-modules/contracts/interfaces/ICrossChainModule.sol b/utils/core-modules/contracts/interfaces/ICrossChainModule.sol new file mode 100644 index 0000000000..644f5e8f1f --- /dev/null +++ b/utils/core-modules/contracts/interfaces/ICrossChainModule.sol @@ -0,0 +1,30 @@ +//SPDX-License-Identifier: MIT +pragma solidity >=0.8.11 <0.9.0; + +/** + * @title Module with assorted cross-chain functions. + */ +interface ICrossChainModule { + /** + * @notice Emitted when a new cross chain network becomes supported by the protocol + */ + event NewSupportedCrossChainNetwork(uint64 newChainId); + + /** + * @notice Configure CCIP addresses router. + * @param ccipRouter The address on this chain to which CCIP messages will be sent or received. + */ + function configureChainlinkCrossChain(address ccipRouter) external; + + /** + * @notice Used to add new cross chain networks to the protocol + * Ignores a network if it matches the current chain id + * Ignores a network if it has already been added, but still updates target if changed + * @param ccipSelectors the ccip "selector" which maps to the chain id on the same index. must be same length as `supportedNetworks` + * @return numRegistered the number of networks that were actually registered + */ + function setSupportedCrossChainNetworks( + uint64[] memory supportedNetworks, + uint64[] memory ccipSelectors + ) external returns (uint256 numRegistered); +} diff --git a/protocol/synthetix/contracts/interfaces/external/IAny2EVMMessageReceiver.sol b/utils/core-modules/contracts/interfaces/external/IAny2EVMMessageReceiver.sol similarity index 100% rename from protocol/synthetix/contracts/interfaces/external/IAny2EVMMessageReceiver.sol rename to utils/core-modules/contracts/interfaces/external/IAny2EVMMessageReceiver.sol diff --git a/protocol/synthetix/contracts/interfaces/external/ICcipRouterClient.sol b/utils/core-modules/contracts/interfaces/external/ICcipRouterClient.sol similarity index 100% rename from protocol/synthetix/contracts/interfaces/external/ICcipRouterClient.sol rename to utils/core-modules/contracts/interfaces/external/ICcipRouterClient.sol diff --git a/utils/core-modules/contracts/mocks/CcipRouterMock.sol b/utils/core-modules/contracts/mocks/CcipRouterMock.sol new file mode 100644 index 0000000000..5e091ab7b3 --- /dev/null +++ b/utils/core-modules/contracts/mocks/CcipRouterMock.sol @@ -0,0 +1,49 @@ +//SPDX-License-Identifier: MIT +pragma solidity >=0.8.4; + +import {CcipClient} from "../utils/CcipClient.sol"; +import {IAny2EVMMessageReceiver} from "../interfaces/external/IAny2EVMMessageReceiver.sol"; + +contract CcipRouterMock { + event CCIPSend( + uint64 destinationChainSelector, + CcipClient.EVM2AnyMessage message, + bytes32 messageId + ); + + uint256 public sendNonce = 0; + + function ccipSend( + uint64 destinationChainSelector, + CcipClient.EVM2AnyMessage calldata message + ) external payable virtual returns (bytes32 messageId) { + sendNonce += 1; + bytes32 _messageId = keccak256(abi.encodePacked(sendNonce)); + emit CCIPSend(destinationChainSelector, message, _messageId); + return _messageId; + } + + function getFee( + uint64, // destinationChainSelector + CcipClient.EVM2AnyMessage memory // message + ) external view virtual returns (uint256 fee) { + // TODO: some mock fee, maybe this should be hardcoded? more intelligent? + return 0; + } + + function __ccipReceive( + address target, + CcipClient.Any2EVMMessage memory message + ) external payable { + (bool success, bytes memory result) = target.call( + abi.encodeWithSelector(IAny2EVMMessageReceiver.ccipReceive.selector, message) + ); + + if (!success) { + uint256 len = result.length; + assembly { + revert(add(result, 0x20), len) + } + } + } +} diff --git a/utils/core-modules/contracts/modules/CcipReceiverModule.sol b/utils/core-modules/contracts/modules/CcipReceiverModule.sol new file mode 100644 index 0000000000..392bd6ff46 --- /dev/null +++ b/utils/core-modules/contracts/modules/CcipReceiverModule.sol @@ -0,0 +1,20 @@ +//SPDX-License-Identifier: MIT +pragma solidity >=0.8.11 <0.9.0; + +import "@synthetixio/core-contracts/contracts/ownership/OwnableStorage.sol"; + +import "../interfaces/external/IAny2EVMMessageReceiver.sol"; +import "../interfaces/IAssociatedSystemsModule.sol"; + +import "../storage/AssociatedSystem.sol"; +import "../storage/CrossChain.sol"; + +/** + * @title Module with assorted utility functions. + * @dev See IUtilsModule. + */ +contract CcipReceiverModule is IAny2EVMMessageReceiver { + function ccipReceive(CcipClient.Any2EVMMessage memory message) external { + CrossChain.processCcipReceive(CrossChain.load(), message); + } +} diff --git a/utils/core-modules/contracts/modules/CrossChainModule.sol b/utils/core-modules/contracts/modules/CrossChainModule.sol new file mode 100644 index 0000000000..87214a1838 --- /dev/null +++ b/utils/core-modules/contracts/modules/CrossChainModule.sol @@ -0,0 +1,70 @@ +//SPDX-License-Identifier: MIT +pragma solidity >=0.8.11 <0.9.0; + +import "@synthetixio/core-contracts/contracts/ownership/OwnableStorage.sol"; +import "@synthetixio/core-contracts/contracts/errors/ParameterError.sol"; +import "@synthetixio/core-contracts/contracts/utils/SafeCast.sol"; + +import "../interfaces/IAssociatedSystemsModule.sol"; +import "../interfaces/external/IAny2EVMMessageReceiver.sol"; +import "../interfaces/ICrossChainModule.sol"; + +import "../storage/AssociatedSystem.sol"; +import "../storage/CrossChain.sol"; + +/** + * @title Module with assorted cross-chain functions. + * @dev See ICrossChainModule. + */ +contract CrossChainModule is ICrossChainModule { + using AssociatedSystem for AssociatedSystem.Data; + using SetUtil for SetUtil.UintSet; + using SafeCastU256 for uint256; + + /** + * @inheritdoc ICrossChainModule + */ + function configureChainlinkCrossChain(address ccipRouter) external override { + OwnableStorage.onlyOwner(); + + CrossChain.Data storage cc = CrossChain.load(); + + cc.ccipRouter = ICcipRouterClient(ccipRouter); + } + + /** + * @inheritdoc ICrossChainModule + */ + function setSupportedCrossChainNetworks( + uint64[] memory supportedNetworks, + uint64[] memory ccipSelectors + ) public returns (uint256 numRegistered) { + OwnableStorage.onlyOwner(); + + if (ccipSelectors.length != supportedNetworks.length) { + revert ParameterError.InvalidParameter("ccipSelectors", "must match length"); + } + + CrossChain.Data storage cc = CrossChain.load(); + for (uint i = 0; i < supportedNetworks.length; i++) { + uint64 chainId = supportedNetworks[i]; + + if (!cc.supportedNetworks.contains(chainId)) { + numRegistered++; + cc.supportedNetworks.add(chainId); + emit NewSupportedCrossChainNetwork(chainId); + } + + cc.ccipChainIdToSelector[chainId] = ccipSelectors[i]; + cc.ccipSelectorToChainId[ccipSelectors[i]] = chainId; + } + + uint64 myChainId = block.chainid.to64(); + if (!cc.supportedNetworks.contains(myChainId)) { + revert ParameterError.InvalidParameter( + "supportedNetworks", + "must include current chain" + ); + } + } +} diff --git a/protocol/synthetix/contracts/storage/CrossChain.sol b/utils/core-modules/contracts/storage/CrossChain.sol similarity index 54% rename from protocol/synthetix/contracts/storage/CrossChain.sol rename to utils/core-modules/contracts/storage/CrossChain.sol index fa3bb8a1d6..a1207e374f 100644 --- a/protocol/synthetix/contracts/storage/CrossChain.sol +++ b/utils/core-modules/contracts/storage/CrossChain.sol @@ -4,25 +4,28 @@ pragma solidity >=0.8.11 <0.9.0; import {SetUtil} from "@synthetixio/core-contracts/contracts/utils/SetUtil.sol"; import {AccessError} from "@synthetixio/core-contracts/contracts/errors/AccessError.sol"; +import "@synthetixio/core-contracts/contracts/utils/SafeCast.sol"; import "@synthetixio/core-contracts/contracts/utils/ERC2771Context.sol"; import "@synthetixio/core-contracts/contracts/interfaces/IERC20.sol"; import "../interfaces/external/ICcipRouterClient.sol"; /** - * @title System wide configuration for anything + * @title System wide configuration for anything related to cross-chain */ library CrossChain { using SetUtil for SetUtil.UintSet; + using SafeCastU256 for uint256; event ProcessedCcipMessage(bytes payload, bytes result); error NotCcipRouter(address); error UnsupportedNetwork(uint64); + error InvalidNetwork(uint64); error InsufficientCcipFee(uint256 requiredAmount, uint256 availableAmount); error InvalidMessage(); bytes32 private constant _SLOT_CROSS_CHAIN = - keccak256(abi.encode("io.synthetix.synthetix.CrossChain")); + keccak256(abi.encode("io.synthetix.core-modules.CrossChain")); struct Data { ICcipRouterClient ccipRouter; @@ -38,6 +41,12 @@ library CrossChain { } } + function validateChainId(Data storage self, uint256 chainId) internal view { + if (!self.supportedNetworks.contains(chainId)) { + revert UnsupportedNetwork(chainId.to64()); + } + } + function processCcipReceive(Data storage self, CcipClient.Any2EVMMessage memory data) internal { if ( address(self.ccipRouter) == address(0) || @@ -59,6 +68,8 @@ library CrossChain { address caller; bytes memory payload; + bool success; + bytes memory result; if (data.tokenAmounts.length == 1) { address to = abi.decode(data.data, (address)); @@ -69,14 +80,16 @@ library CrossChain { to, data.tokenAmounts[0].amount ); + + // at this point, everything should be good to send the message to ourselves. + // the below `onlyCrossChain` function will verify that the caller is self + (success, result) = caller.call(payload); + } else if (data.tokenAmounts.length == 0) { + (success, result) = address(this).call(data.data); } else { revert InvalidMessage(); } - // at this point, everything should be good to send the message to ourselves. - // the below `onlyCrossChain` function will verify that the caller is self - (bool success, bytes memory result) = caller.call(payload); - if (!success) { uint len = result.length; assembly { @@ -93,6 +106,81 @@ library CrossChain { } } + function getChainIdAt(Data storage self, uint64 index) internal view returns (uint64) { + return self.supportedNetworks.valueAt(index + 1).to64(); + } + + function getSupportedNetworks(Data storage self) internal view returns (uint64[] memory) { + SetUtil.UintSet storage supportedNetworks = self.supportedNetworks; + uint256[] memory supportedChains = supportedNetworks.values(); + uint64[] memory chains = new uint64[](supportedChains.length); + for (uint i = 0; i < supportedChains.length; i++) { + uint64 chainId = supportedChains[i].to64(); + chains[i] = chainId; + } + return chains; + } + + function transmit( + Data storage self, + uint64 chainId, + bytes memory data, + uint256 gasLimit + ) internal returns (uint256 gasTokenUsed) { + uint64[] memory chains = new uint64[](1); + chains[0] = chainId; + return broadcast(self, chains, data, gasLimit); + } + + /** + * @dev Sends a message to one or more chains + */ + function broadcast( + Data storage self, + uint64[] memory chains, + bytes memory data, + uint256 gasLimit + ) internal returns (uint256 gasTokenUsed) { + ICcipRouterClient router = self.ccipRouter; + + for (uint i = 0; i < chains.length; i++) { + uint64 destChainId = chains[i]; + + if (destChainId == block.chainid) { + (bool success, bytes memory result) = address(this).call(data); + + if (!success) { + uint256 len = result.length; + assembly { + revert(add(result, 0x20), len) + } + } + } else { + CcipClient.EVM2AnyMessage memory sentMsg = CcipClient.EVM2AnyMessage( + abi.encode(address(this)), // abi.encode(receiver address) for dest EVM chains + data, // Data payload + new CcipClient.EVMTokenAmount[](0), // Token transfers + address(0), // Address of feeToken. address(0) means you will send msg.value. + CcipClient._argsToBytes(CcipClient.EVMExtraArgsV1(gasLimit, false)) + ); + + uint64 chainSelector = self.ccipChainIdToSelector[destChainId]; + uint256 fee = router.getFee(chainSelector, sentMsg); + + // need to check sufficient fee here or else the error is very confusing + if (address(this).balance < fee) { + revert InsufficientCcipFee(fee, address(this).balance); + } + + router.ccipSend{value: fee}(chainSelector, sentMsg); + + gasTokenUsed += fee; + } + } + + CrossChain.refundLeftoverGas(gasTokenUsed); + } + /** * @dev Transfers tokens to a destination chain. */ @@ -109,6 +197,7 @@ library CrossChain { tokenAmounts[0] = CcipClient.EVMTokenAmount(token, amount); bytes memory data = abi.encode(ERC2771Context._msgSender()); + CcipClient.EVM2AnyMessage memory sentMsg = CcipClient.EVM2AnyMessage( abi.encode(address(this)), // abi.encode(receiver address) for dest EVM chains data, @@ -140,7 +229,7 @@ library CrossChain { if (!success) { uint256 len = result.length; assembly { - revert(result, len) + revert(add(result, 0x20), len) } } } diff --git a/protocol/synthetix/contracts/utils/CcipClient.sol b/utils/core-modules/contracts/utils/CcipClient.sol similarity index 100% rename from protocol/synthetix/contracts/utils/CcipClient.sol rename to utils/core-modules/contracts/utils/CcipClient.sol diff --git a/utils/core-modules/package.json b/utils/core-modules/package.json index b3e9cfc680..fbac21c892 100644 --- a/utils/core-modules/package.json +++ b/utils/core-modules/package.json @@ -21,10 +21,12 @@ "author": "Synthetix", "license": "MIT", "devDependencies": { + "@foundry-rs/hardhat-anvil": "^0.1.7", "@synthetixio/common-config": "workspace:*", "@synthetixio/core-utils": "workspace:*", "@synthetixio/router": "^3.3.0", "ethers": "^5.7.2", + "get-port": "^7.0.0", "hardhat": "^2.19.0" }, "gitHead": "ba5a9730df248cd1999b5a6fd1bf67b307b95eec" diff --git a/utils/core-modules/test/helpers/cannon.ts b/utils/core-modules/test/helpers/cannon.ts new file mode 100644 index 0000000000..fa75335581 --- /dev/null +++ b/utils/core-modules/test/helpers/cannon.ts @@ -0,0 +1,82 @@ +import { AnvilServer } from '@foundry-rs/hardhat-anvil/dist/src/anvil-server'; +import { CannonWrapperGenericProvider, ContractArtifact } from '@usecannon/builder'; +import { build, inspect, loadCannonfile } from '@usecannon/cli'; +import { ethers } from 'ethers'; + +interface NodeOptions { + port?: number; + chainId?: number; +} + +interface BuildOptions { + cannonfile: string; + chainId: number; + getArtifact: (name: string) => Promise; + pkgInfo: object; + projectDirectory: string; + port?: number; + impersonate?: string; + wipe?: boolean; +} + +interface InspectOptions { + packageRef: string; + chainId: number; + writeDeployments: string; +} + +export async function launchNode(options: NodeOptions = {}) { + if (typeof options.port === 'undefined' || options.port === 0) { + const { default: getPort } = await import('get-port'); + options.port = await getPort(); + } + + const { port } = options; + const server = await AnvilServer.launch({ launch: true, ...options }, false); + const rpcUrl = `http://127.0.0.1:${port}/`; + + return { server, port, rpcUrl }; +} + +export async function cannonBuild(options: BuildOptions) { + const node = await launchNode({ chainId: options.chainId, port: options.port }); + + const provider = new CannonWrapperGenericProvider( + {}, + new ethers.providers.JsonRpcProvider(node.rpcUrl) + ); + + const { name, version, def } = await loadCannonfile(options.cannonfile); + + async function getSigner(addr: string) { + await provider.send('hardhat_impersonateAccount', [addr]); + await provider.send('hardhat_setBalance', [addr, `0x${(1e22).toString(16)}`]); + return provider.getSigner(addr); + } + + const { outputs } = await build({ + provider, + def, + packageDefinition: { + name, + version, + settings: {}, + }, + getArtifact: options.getArtifact, + getSigner, + getDefaultSigner: options.impersonate ? () => getSigner(options.impersonate!) : undefined, + pkgInfo: options.pkgInfo, + projectDirectory: options.projectDirectory, + wipe: options.wipe, + registryPriority: 'local', + publicSourceCode: false, + }); + + const packageRef = `${name}:${version}`; + + return { packageRef, options, provider, outputs }; +} + +export async function cannonInspect(options: InspectOptions) { + return inspect(options.packageRef, options.chainId, '', false, options.writeDeployments); +} diff --git a/utils/core-modules/test/helpers/ccip.ts b/utils/core-modules/test/helpers/ccip.ts new file mode 100644 index 0000000000..b7e1f62ae3 --- /dev/null +++ b/utils/core-modules/test/helpers/ccip.ts @@ -0,0 +1,59 @@ +import { findEvent, findSingleEvent } from '@synthetixio/core-utils/src/utils/ethers/events'; +import { ethers } from 'ethers'; +import { CcipRouterMock__factory } from '../../typechain-types/factories/contracts/mocks/CcipRouterMock__factory'; + +import type { CcipRouterMock } from '../../typechain-types/contracts/mocks/CcipRouterMock'; + +export const CcipRouter = new ethers.Contract( + ethers.constants.AddressZero, + CcipRouterMock__factory.abi +) as CcipRouterMock; + +/** + * Given a receipt from a transaction that called the ccipSend method from CcipRouterMock + */ +export async function ccipReceive({ + rx, + sourceChainSelector, + targetSigner, + ccipAddress, + index, +}: { + rx: ethers.ContractReceipt; + sourceChainSelector: ethers.BigNumberish; + targetSigner: ethers.Signer; + ccipAddress: string; + index?: number; +}) { + let evt; + if (typeof index !== 'number') { + evt = findSingleEvent({ + eventName: 'CCIPSend', + receipt: rx, + contract: CcipRouter, + }); + } else { + evt = findEvent({ + eventName: 'CCIPSend', + receipt: rx, + contract: CcipRouter, + } as Parameters[0]); + evt = Array.isArray(evt) ? evt[index] : evt; + } + + if (evt && evt.args) { + const message = { + messageId: evt.args.messageId, + sourceChainSelector, + sender: ethers.utils.defaultAbiCoder.encode(['address'], [rx.to]), + data: evt.args.message.data, + tokenAmounts: [], + }; + + return CcipRouter.attach(ccipAddress) + .connect(targetSigner) + .__ccipReceive(rx.to, message, { value: 0 }); + } else { + throw new Error('no CCIPSend event found'); + } +} diff --git a/utils/core-utils/src/utils/ethers/events.ts b/utils/core-utils/src/utils/ethers/events.ts index d92a5c2fa9..e9dcd7e256 100644 --- a/utils/core-utils/src/utils/ethers/events.ts +++ b/utils/core-utils/src/utils/ethers/events.ts @@ -1,5 +1,5 @@ -import { LogDescription } from '@ethersproject/abi/lib/interface'; import { Result } from '@ethersproject/abi'; +import { LogDescription } from '@ethersproject/abi/lib/interface'; import { ethers } from 'ethers'; /** @@ -63,11 +63,13 @@ export function parseLogs({ contract: ethers.Contract; logs: ethers.providers.Log[]; }) { - return logs.map((log) => { - const event = contract.interface.parseLog(log) as unknown as ethers.Event; - event.event = (event as unknown as LogDescription).name; - return event; - }); + return logs + .filter((log) => log.data !== '0x') + .map((log) => { + const event = contract.interface.parseLog(log) as unknown as ethers.Event; + event.event = (event as unknown as LogDescription).name; + return event; + }); } interface EventWithArgs extends ethers.Event { diff --git a/utils/deps/deps.js b/utils/deps/deps.js index ea9e9e7c63..a9324bcd67 100755 --- a/utils/deps/deps.js +++ b/utils/deps/deps.js @@ -77,11 +77,15 @@ async function run() { const packageJson = JSON.parse(await fs.readFile(`${location}/package.json`, 'utf-8')); - let { dependencies, devDependencies, missing } = await depcheck(location, { + let { dependencies, devDependencies, missing, invalidFiles } = await depcheck(location, { ...options, ...packageJson.depcheck, package: packageJson, }); + if (Object.keys(invalidFiles).length > 0) { + console.error('ERROR: Invalid files', invalidFiles); + process.exit(1); + } dependencies.sort(); devDependencies.sort(); diff --git a/utils/hardhat-storage/src/internal/render-testable-storage.ts b/utils/hardhat-storage/src/internal/render-testable-storage.ts index afe10d979d..3e1ba4460c 100644 --- a/utils/hardhat-storage/src/internal/render-testable-storage.ts +++ b/utils/hardhat-storage/src/internal/render-testable-storage.ts @@ -11,6 +11,7 @@ interface TestableStorageTemplateInputs { loadParams?: string; loadInject?: string; + supportedLoadFunction?: boolean; fields: { name: string; @@ -118,6 +119,7 @@ function _generateTemplateInputs( const methods: TestableStorageTemplateInputs['methods'] = []; let loadParams: TestableStorageTemplateInputs['loadParams'] = undefined; let loadInject: TestableStorageTemplateInputs['loadInject'] = undefined; + let supportedLoadFunction = false; for (const functionDefinition of findAll('FunctionDefinition', contractDefinition)) { if (functionDefinition.visibility === 'private') { @@ -139,6 +141,8 @@ function _generateTemplateInputs( .map((p) => '_load_' + p.name) .join(', '); + supportedLoadFunction = true; + continue; // we have handled the load function } @@ -198,6 +202,7 @@ function _generateTemplateInputs( relativeSourceName, loadParams, loadInject, + supportedLoadFunction, libraryName: contractDefinition.name, fields, indexedFields, diff --git a/utils/hardhat-storage/templates/TestableStorage.sol.mustache b/utils/hardhat-storage/templates/TestableStorage.sol.mustache index d25f972025..92024cecc9 100644 --- a/utils/hardhat-storage/templates/TestableStorage.sol.mustache +++ b/utils/hardhat-storage/templates/TestableStorage.sol.mustache @@ -11,14 +11,14 @@ pragma solidity ^0.8.0; import "{{{relativeSourceName}}}"; contract Testable{{{libraryName}}}Storage { - {{#loadParams}} + {{#loadParams}} function _getInstanceStore({{{loadParams}}}) internal pure returns ({{{libraryName}}}.Data storage) { return {{{libraryName}}}.load({{{loadInject}}}); } {{/loadParams}} {{^loadParams}} function _getInstanceStore() internal pure returns ({{{libraryName}}}.Data storage data) { - bytes32 s = keccak256(abi.encode("Testable{{{libraryName}}}")); + bytes32 s = keccak256(abi.encode("{{{libraryName}}}")); assembly { data.slot := s } @@ -27,36 +27,40 @@ contract Testable{{{libraryName}}}Storage { {{#fields}} function {{{libraryName}}}_set_{{{name}}}({{#loadParams}}{{{loadParams}}}, {{/loadParams}}{{{type}}} val) external { - {{{libraryName}}}.Data storage store = _getInstanceStore({{{loadInject}}}); + {{{libraryName}}}.Data storage store = _getInstanceStore({{#loadParams}}{{{loadInject}}}{{/loadParams}}); + store.{{{name}}} = val; } function {{{libraryName}}}_get_{{{name}}}({{{loadParams}}}) external view returns ({{type}}) { - {{{libraryName}}}.Data storage store = _getInstanceStore({{{loadInject}}}); + {{{libraryName}}}.Data storage store = _getInstanceStore({{#loadParams}}{{{loadInject}}}{{/loadParams}}); + return store.{{{name}}}; } {{/fields}} {{#indexedFields}} function {{{libraryName}}}_set_{{{name}}}({{#loadParams}}{{{loadParams}}}, {{/loadParams}}{{{indexType}}} idx, {{{type}}} val) external { - {{{libraryName}}}.Data storage store = _getInstanceStore({{{loadInject}}}); + {{{libraryName}}}.Data storage store = _getInstanceStore({{#loadParams}}{{{loadInject}}}{{/loadParams}}); + store.{{{name}}}[idx] = val; } function {{{libraryName}}}_get_{{{name}}}({{#loadParams}}{{{loadParams}}}, {{/loadParams}}{{{indexType}}} idx) external view returns ({{type}}) { - {{{libraryName}}}.Data storage store = _getInstanceStore({{{loadInject}}}); + {{{libraryName}}}.Data storage store = _getInstanceStore({{#loadParams}}{{{loadInject}}}{{/loadParams}}); + return store.{{{name}}}[idx]; } {{#isArray}} function {{{libraryName}}}_push_{{{name}}}({{#loadParams}}{{{loadParams}}}, {{/loadParams}}{{{type}}} val) external { - {{{libraryName}}}.Data storage store = _getInstanceStore({{{loadInject}}}); + {{{libraryName}}}.Data storage store = _getInstanceStore({{#loadParams}}{{{loadInject}}}{{/loadParams}}); store.{{{name}}}.push(val); } function {{{libraryName}}}_pop_{{{name}}}({{{loadParams}}}) external { - {{{libraryName}}}.Data storage store = _getInstanceStore({{{loadInject}}}); + {{{libraryName}}}.Data storage store = _getInstanceStore({{#loadParams}}{{{loadInject}}}{{/loadParams}}); store.{{{name}}}.pop(); } @@ -65,7 +69,8 @@ contract Testable{{{libraryName}}}Storage { {{/indexedFields}} {{#methods}} function {{{libraryName}}}_{{{name}}}({{#loadParams}}{{{loadParams}}}{{#params}}, {{/params}}{{/loadParams}}{{{params}}}) external{{#mutability}} {{{mutability}}}{{/mutability}} {{#returns}}returns ({{{returns}}}) {{/returns}}{ - {{{libraryName}}}.Data storage store = _getInstanceStore({{{loadInject}}}); + {{{libraryName}}}.Data storage store = _getInstanceStore({{#loadParams}}{{{loadInject}}}{{/loadParams}}); + return {{{libraryName}}}.{{{name}}}({{{paramsInject}}}); } diff --git a/utils/sample-project/contracts/Proxy.sol b/utils/sample-project/contracts/Proxy.sol index 43e551edd9..85bdf73cc3 100644 --- a/utils/sample-project/contracts/Proxy.sol +++ b/utils/sample-project/contracts/Proxy.sol @@ -4,9 +4,8 @@ pragma solidity >=0.8.11 <0.9.0; import {UUPSProxyWithOwner} from "@synthetixio/core-contracts/contracts/proxy/UUPSProxyWithOwner.sol"; contract Proxy is UUPSProxyWithOwner { - // solhint-disable-next-line no-empty-blocks constructor( address firstImplementation, address initialOwner - ) UUPSProxyWithOwner(firstImplementation, initialOwner) {} + ) UUPSProxyWithOwner(firstImplementation, initialOwner) {} // solhint-disable-line no-empty-blocks } diff --git a/yarn.lock b/yarn.lock index baf679c465..849d3d8efa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1004,6 +1004,39 @@ __metadata: languageName: node linkType: hard +"@foundry-rs/easy-foundryup@npm:^0.1.3": + version: 0.1.3 + resolution: "@foundry-rs/easy-foundryup@npm:0.1.3" + dependencies: + command-exists: "npm:^1.2.9" + ts-interface-checker: "npm:^0.1.9" + checksum: a653a11e670dc0d75b58d4039049706ab078cb9baea22dbc133aabe9edb4421e65bfd08c81f9e23b35985850555764edec5f28f06cf33bf23df37051b3358c43 + languageName: node + linkType: hard + +"@foundry-rs/hardhat-anvil@npm:^0.1.7": + version: 0.1.7 + resolution: "@foundry-rs/hardhat-anvil@npm:0.1.7" + dependencies: + "@foundry-rs/easy-foundryup": "npm:^0.1.3" + "@nomiclabs/hardhat-ethers": "npm:^2.0.0" + "@nomiclabs/hardhat-waffle": "npm:^2.0.0" + "@types/sinon-chai": "npm:^3.2.3" + "@types/web3": "npm:1.0.19" + chalk: "npm:^2.4.2" + debug: "npm:^4.1.1" + ethers: "npm:^5.0.0" + fs-extra: "npm:^7.0.1" + ts-interface-checker: "npm:^0.1.9" + peerDependencies: + "@nomiclabs/hardhat-ethers": ^2.0.0 + ethereum-waffle: ^3.2.0 + ethers: ^5.0.0 + hardhat: ^2.0.0 + checksum: 148b5fdd3005b6635dd55596b3f89842683963ad95c926c5495528305868b24b858fca8ab43b49132926bd679d51999847c34582d982c4e3a87e4d625658e63e + languageName: node + linkType: hard + "@graphprotocol/graph-cli@npm:^0.61.0": version: 0.61.0 resolution: "@graphprotocol/graph-cli@npm:0.61.0" @@ -1978,7 +2011,7 @@ __metadata: languageName: node linkType: hard -"@nomiclabs/hardhat-ethers@npm:^2.2.3": +"@nomiclabs/hardhat-ethers@npm:^2.0.0, @nomiclabs/hardhat-ethers@npm:^2.2.3": version: 2.2.3 resolution: "@nomiclabs/hardhat-ethers@npm:2.2.3" peerDependencies: @@ -2008,6 +2041,19 @@ __metadata: languageName: node linkType: hard +"@nomiclabs/hardhat-waffle@npm:^2.0.0": + version: 2.0.6 + resolution: "@nomiclabs/hardhat-waffle@npm:2.0.6" + peerDependencies: + "@nomiclabs/hardhat-ethers": ^2.0.0 + "@types/sinon-chai": ^3.2.3 + ethereum-waffle: "*" + ethers: ^5.0.0 + hardhat: ^2.0.0 + checksum: 55e5edd8ddbd9a1579bfd997d96e431057850ef640f826f0f0c22dcfc9db46e04f4ecd8f3ca8b3d1715a951e184b8bcfadf94ceee5f2db000ac896f617cfa2ae + languageName: node + linkType: hard + "@npmcli/agent@npm:^2.0.0": version: 2.1.1 resolution: "@npmcli/agent@npm:2.1.1" @@ -2867,10 +2913,12 @@ __metadata: version: 0.0.0-use.local resolution: "@synthetixio/core-modules@workspace:utils/core-modules" dependencies: + "@foundry-rs/hardhat-anvil": "npm:^0.1.7" "@synthetixio/common-config": "workspace:*" "@synthetixio/core-utils": "workspace:*" "@synthetixio/router": "npm:^3.3.0" ethers: "npm:^5.7.2" + get-port: "npm:^7.0.0" hardhat: "npm:^2.19.0" languageName: unknown linkType: soft @@ -2960,8 +3008,10 @@ __metadata: "@synthetixio/core-utils": "workspace:*" "@synthetixio/docgen": "workspace:*" "@synthetixio/router": "npm:^3.3.0" + "@usecannon/cli": "npm:^2.9.11" hardhat: "npm:^2.19.0" solidity-docgen: "npm:^0.6.0-beta.36" + typechain: "npm:^8.3.2" languageName: unknown linkType: soft @@ -3348,6 +3398,15 @@ __metadata: languageName: node linkType: hard +"@types/bn.js@npm:*, @types/bn.js@npm:^5.1.0": + version: 5.1.5 + resolution: "@types/bn.js@npm:5.1.5" + dependencies: + "@types/node": "npm:*" + checksum: 9719330c86aeae0a6a447c974cf0f853ba3660ede20de61f435b03d699e30e6d8b35bf71a8dc9fdc8317784438e83177644ba068ed653d0ae0106e1ecbfe289e + languageName: node + linkType: hard + "@types/bn.js@npm:^4.11.3": version: 4.11.6 resolution: "@types/bn.js@npm:4.11.6" @@ -3357,12 +3416,10 @@ __metadata: languageName: node linkType: hard -"@types/bn.js@npm:^5.1.0": - version: 5.1.1 - resolution: "@types/bn.js@npm:5.1.1" - dependencies: - "@types/node": "npm:*" - checksum: cf2c45833e67ecfc45e5336151965a47857431640b61708b6e4dc81d88ed53585c9b30be59abbbee609cdf7a63828e5b8a58c1a27eb4306e5cb7ddd9bad46650 +"@types/chai@npm:*": + version: 4.3.10 + resolution: "@types/chai@npm:4.3.10" + checksum: a52b2c603cf293f0cfce304474b2844d7d03279713ebe3d310f2710d72ab2db14940a187fac389bfa12c58eace62ed477125321813c818f31d82e388ec405a73 languageName: node linkType: hard @@ -3639,6 +3696,32 @@ __metadata: languageName: node linkType: hard +"@types/sinon-chai@npm:^3.2.3": + version: 3.2.12 + resolution: "@types/sinon-chai@npm:3.2.12" + dependencies: + "@types/chai": "npm:*" + "@types/sinon": "npm:*" + checksum: d906f2f766613534c5e9fe1437ec740fb6a9a550f02d1a0abe180c5f18fe73a99f0c12935195404d42f079f5f72a371e16b81e2aef963a6ef0ee0ed9d5d7f391 + languageName: node + linkType: hard + +"@types/sinon@npm:*": + version: 17.0.1 + resolution: "@types/sinon@npm:17.0.1" + dependencies: + "@types/sinonjs__fake-timers": "npm:*" + checksum: 3459e48abdc628e3c0e4d6240382987f003eb8b011a862246ac10c1b8763b29d4b525e4efcfccb76aeb67db534b5f91b15cb6598e47275f35a0ee234bffbad79 + languageName: node + linkType: hard + +"@types/sinonjs__fake-timers@npm:*": + version: 8.1.5 + resolution: "@types/sinonjs__fake-timers@npm:8.1.5" + checksum: 3a0b285fcb8e1eca435266faa27ffff206608b69041022a42857274e44d9305822e85af5e7a43a9fae78d2ab7dc0fcb49f3ae3bda1fa81f0203064dbf5afd4f6 + languageName: node + linkType: hard + "@types/stack-utils@npm:^2.0.0": version: 2.0.1 resolution: "@types/stack-utils@npm:2.0.1" @@ -3646,6 +3729,23 @@ __metadata: languageName: node linkType: hard +"@types/underscore@npm:*": + version: 1.11.14 + resolution: "@types/underscore@npm:1.11.14" + checksum: 8dc0619698e6bbc125cfb684886e67f2972c4f9c22c4213245f8cc3c9b9a22fc5122df6aec7551234c250bfe520d10e396c413abaa60fb431c676b27bb1f36bf + languageName: node + linkType: hard + +"@types/web3@npm:1.0.19": + version: 1.0.19 + resolution: "@types/web3@npm:1.0.19" + dependencies: + "@types/bn.js": "npm:*" + "@types/underscore": "npm:*" + checksum: bf61a77d63cafed92a431df32505ea7e5d91f044f50ecd57e22efa10c76ec1093367403c060b8e72021f4e52513aceebb9c3edfb801b90dee09dc16dc0339d25 + languageName: node + linkType: hard + "@types/ws@npm:^7.4.4": version: 7.4.7 resolution: "@types/ws@npm:7.4.7" @@ -5744,7 +5844,7 @@ __metadata: languageName: node linkType: hard -"command-exists@npm:^1.2.8": +"command-exists@npm:^1.2.8, command-exists@npm:^1.2.9": version: 1.2.9 resolution: "command-exists@npm:1.2.9" checksum: 46fb3c4d626ca5a9d274f8fe241230817496abc34d12911505370b7411999e183c11adff7078dd8a03ec4cf1391290facda40c6a4faac8203ae38c985eaedd63 @@ -7237,7 +7337,7 @@ __metadata: languageName: node linkType: hard -"ethers@npm:^5.7.1, ethers@npm:^5.7.2": +"ethers@npm:^5.0.0, ethers@npm:^5.7.1, ethers@npm:^5.7.2": version: 5.7.2 resolution: "ethers@npm:5.7.2" dependencies: @@ -8095,6 +8195,13 @@ __metadata: languageName: node linkType: hard +"get-port@npm:^7.0.0": + version: 7.0.0 + resolution: "get-port@npm:7.0.0" + checksum: e9087f62d086bbb70f20c0a208e7cac552679c1426e29e0607eb1b8907a5cc4509337d5971b7f635385cd2a773a14cd21b7d9c3254a2eb5ebeaf5f8fde19fb07 + languageName: node + linkType: hard + "get-stream@npm:^6.0.0, get-stream@npm:^6.0.1": version: 6.0.1 resolution: "get-stream@npm:6.0.1" @@ -15818,6 +15925,13 @@ __metadata: languageName: node linkType: hard +"ts-interface-checker@npm:^0.1.9": + version: 0.1.13 + resolution: "ts-interface-checker@npm:0.1.13" + checksum: 9f7346b9e25bade7a1050c001ec5a4f7023909c0e1644c5a96ae20703a131627f081479e6622a4ecee2177283d0069e651e507bedadd3904fc4010ab28ffce00 + languageName: node + linkType: hard + "ts-jest@npm:^29.1.1": version: 29.1.1 resolution: "ts-jest@npm:29.1.1"