Repository: Synthetixio/synthetix-v3
Pull request #696 (2022-01-10 12:01:46)

FEATURE: ElectionModule
ElectionModule implementation feature branch.

Spec: https://hackmd.io/@wDuYijTaRY6GbPExcc1gWw/HkNyRskjK
Reference implementation: https://github.com/Synthetixio/synthetix-v3/pull/632

codecov[bot]: # [Codecov](https://codecov.io/gh/Synthetixio/synthetix-v3/pull/696?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Synthetixio) Report
> Merging [#696](https://codecov.io/gh/Synthetixio/synthetix-v3/pull/696?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Synthetixio) (9d74024) into [main](https://codecov.io/gh/Synthetixio/synthetix-v3/commit/098aae673f24acefc467e191c227cd9b534c6d35?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Synthetixio) (098aae6) will **not change** coverage.
> The diff coverage is `n/a`.

[![Impacted file tree graph](https://codecov.io/gh/Synthetixio/synthetix-v3/pull/696/graphs/tree.svg?width=650&height=150&src=pr&token=B9BK0U5KAT&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Synthetixio)](https://codecov.io/gh/Synthetixio/synthetix-v3/pull/696?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Synthetixio)

```diff
@@           Coverage Diff           @@
##             main     #696   +/-   ##
=======================================
  Coverage   94.68%   94.68%           
=======================================
  Files          63       63           
  Lines        1072     1072           
  Branches       49       49           
=======================================
  Hits         1015     1015           
  Misses         57       57           
```

| Flag | Coverage Δ | |
|---|---|---|
| core-contracts | `100.00% <ø> (ø)` | |
| core-js | `99.14% <ø> (ø)` | |
| core-modules | `100.00% <ø> (ø)` | |
| deployer | `93.03% <ø> (ø)` | |
| synthetix-main | `100.00% <ø> (ø)` | |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Synthetixio#carryforward-flags-in-the-pull-request-comment) to find out more.


------

[Continue to review full report at Codecov](https://codecov.io/gh/Synthetixio/synthetix-v3/pull/696?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Synthetixio).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Synthetixio)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/Synthetixio/synthetix-v3/pull/696?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Synthetixio). Last update [098aae6...9d74024](https://codecov.io/gh/Synthetixio/synthetix-v3/pull/696?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Synthetixio). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Synthetixio).


Patch:
diff --git a/packages/cli/subtasks/pick-function.js b/packages/cli/subtasks/pick-function.js
index 4bf0b23a..c7d8f7a1 100644
--- a/packages/cli/subtasks/pick-function.js
+++ b/packages/cli/subtasks/pick-function.js
@@ -9,7 +9,7 @@ subtask(SUBTASK_PICK_FUNCTION, 'Pick a function from the given contract').setAct
   async (taskArguments, hre) => {
     const abi = hre.deployer.deployment.abis[hre.cli.contractFullyQualifiedName];
     const abiFunctions = abi.filter((abiItem) => abiItem.name && abiItem.type === 'function');
-    const selectors = await getSelectors(abi);
+    const selectors = await getSelectors(abi, hre.config.deployer.routerFunctionFilter);
 
     const choices = abiFunctions.map((functionAbi) => {
       const fullSignature = getFullFunctionSignature(functionAbi);
diff --git a/packages/core-js/utils/ethers/contracts.js b/packages/core-js/utils/ethers/contracts.js
index df2b93b6..c131c949 100644
--- a/packages/core-js/utils/ethers/contracts.js
+++ b/packages/core-js/utils/ethers/contracts.js
@@ -15,15 +15,14 @@ async function getRemoteBytecode(address, provider) {
   return await provider.getCode(address);
 }
 
-async function getSelectors(contractAbi) {
+async function getSelectors(contractAbi, functionFilter = () => true) {
   const contract = await new ethers.Contract(
     '0x0000000000000000000000000000000000000001',
     contractAbi
   );
 
   return contract.interface.fragments.reduce((selectors, fragment) => {
-    // Only add methods, and filter out the ones added during instrumentation by solidity-coverage
-    if (fragment.type === 'function' && !fragment.name.startsWith('c_')) {
+    if (fragment.type === 'function' && functionFilter(fragment.name)) {
       selectors.push({
         name: fragment.name,
         selector: contract.interface.getSighash(fragment),
diff --git a/packages/core-js/utils/ethers/events.js b/packages/core-js/utils/ethers/events.js
index d027b1e5..9b5143b4 100644
--- a/packages/core-js/utils/ethers/events.js
+++ b/packages/core-js/utils/ethers/events.js
@@ -13,12 +13,12 @@ function findEvent({ receipt, eventName, contract = undefined }) {
     events = parseLogs({ contract, logs: receipt.logs });
   }
 
-  const event = events.find((e) => e.event === eventName);
-  if (!event) {
+  events = events.filter((e) => e.event === eventName);
+  if (!events || events.length === 0) {
     throw new Error(`Cannot find event ${eventName} in receipt.`);
   }
 
-  return event;
+  return events.length === 1 ? events[0] : events;
 }
 
 /**
diff --git a/packages/core-js/utils/misc/dates.js b/packages/core-js/utils/misc/dates.js
new file mode 100644
index 00000000..163580e6
--- /dev/null
+++ b/packages/core-js/utils/misc/dates.js
@@ -0,0 +1,24 @@
+/**
+ * Get the date in format `YYYY-MM-DD`
+ * @param {Date} [date=new Date()]
+ * @returns {string}
+ */
+function formatDate(date = new Date()) {
+  if (!(date instanceof Date)) {
+    throw new Error('Invalid date given');
+  }
+
+  return date.toISOString().slice(0, 10);
+}
+
+function getUnixTimestamp() {
+  return Math.floor(new Date().getTime() / 1000);
+}
+
+const daysToSeconds = (days) => days * 3600 * 24;
+
+module.exports = {
+  formatDate,
+  getUnixTimestamp,
+  daysToSeconds,
+};
diff --git a/packages/core-js/utils/misc/get-date.js b/packages/core-js/utils/misc/get-date.js
deleted file mode 100644
index efd50444..00000000
--- a/packages/core-js/utils/misc/get-date.js
+++ /dev/null
@@ -1,12 +0,0 @@
-/**
- * Get the date in format `YYYY-MM-DD`
- * @param {Date} [date=new Date()]
- * @returns {string}
- */
-module.exports = function getDate(date = new Date()) {
-  if (!(date instanceof Date)) {
-    throw new Error('Invalid date given');
-  }
-
-  return date.toISOString().slice(0, 10);
-};
diff --git a/packages/core-modules/contracts/interfaces/IElectionModule.sol b/packages/core-modules/contracts/interfaces/IElectionModule.sol
new file mode 100644
index 00000000..1c72257b
--- /dev/null
+++ b/packages/core-modules/contracts/interfaces/IElectionModule.sol
@@ -0,0 +1,137 @@
+//SPDX-License-Identifier: MIT
+pragma solidity ^0.8.0;
+
+interface IElectionModule {
+    // ---------------------------------------
+    // Initialization
+    // ---------------------------------------
+
+    function initializeElectionModule(
+        string memory councilTokenName,
+        string memory councilTokenSymbol,
+        uint64 nominationPeriodStartDate,
+        uint64 votingPeriodStartDate,
+        uint64 epochEndDate
+    ) external;
+
+    function isElectionModuleInitialized() external view returns (bool);
+
+    // ---------------------------------------
+    // Owner write functions
+    // ---------------------------------------
+
+    function upgradeCouncilToken(address newCouncilTokenImplementation) external;
+
+    function tweakEpochSchedule(
+        uint64 newNominationPeriodStartDate,
+        uint64 newVotingPeriodStartDate,
+        uint64 newEpochEndDate
+    ) external;
+
+    function modifyEpochSchedule(
+        uint64 newNominationPeriodStartDate,
+        uint64 newVotingPeriodStartDate,
+        uint64 newEpochEndDate
+    ) external;
+
+    function setMinEpochDurations(
+        uint64 newMinNominationPeriodDuration,
+        uint64 newMinVotingPeriodDuration,
+        uint64 newMinEpochDuration
+    ) external;
+
+    function setMaxDateAdjustmentTolerance(uint64 newMaxDateAdjustmentTolerance) external;
+
+    function setDefaultBallotEvaluationBatchSize(uint newDefaultBallotEvaluationBatchSize) external;
+
+    function setNextEpochSeatCount(uint8 newSeatCount) external;
+
+    // ---------------------------------------
+    // User write functions
+    // ---------------------------------------
+
+    function nominate() external;
+
+    function withdrawNomination() external;
+
+    function elect(address[] calldata candidates) external;
+
+    function evaluate(uint numBallots) external;
+
+    function resolve() external;
+
+    // ---------------------------------------
+    // View functions
+    // ---------------------------------------
+
+    // Settings
+    // ~~~~~~~~~~~~~~~~~~
+
+    function getMinEpochDurations()
+        external
+        view
+        returns (
+            uint64 minNominationPeriodDuration,
+            uint64 minVotingPeriodDuration,
+            uint64 minEpochDuration
+        );
+
+    function getMaxDateAdjustmenTolerance() external view returns (uint64);
+
+    function getDefaultBallotEvaluationBatchSize() external view returns (uint);
+
+    function getNextEpochSeatCount() external view returns (uint8);
+
+    // Epoch and periods
+    // ~~~~~~~~~~~~~~~~~~
+
+    function getEpochIndex() external view returns (uint);
+
+    function getEpochStartDate() external view returns (uint64);
+
+    function getEpochEndDate() external view returns (uint64);
+
+    function getNominationPeriodStartDate() external view returns (uint64);
+
+    function getVotingPeriodStartDate() external view returns (uint64);
+
+    function getCurrentPeriod() external view returns (uint);
+
+    // Nominations
+    // ~~~~~~~~~~~~~~~~~~
+
+    function isNominated(address candidate) external view returns (bool);
+
+    function getNominees() external view returns (address[] memory);
+
+    // Votes
+    // ~~~~~~~~~~~~~~~~~~
+
+    function calculateBallotId(address[] calldata candidates) external pure returns (bytes32);
+
+    function getBallotVoted(address voter) external view returns (bytes32);
+
+    function hasVoted(address voter) external view returns (bool);
+
+    function getVotePower(address voter) external view returns (uint);
+
+    function getBallotVotes(bytes32 ballotId) external view returns (uint);
+
+    function getBallotCandidates(bytes32 ballotId) external view returns (address[] memory);
+
+    // Resolutions
+    // ~~~~~~~~~~~~~~~~~~
+
+    function isElectionEvaluated() external view returns (bool);
+
+    function getCandidateVotes(address candidate) external view returns (uint);
+
+    function getElectionWinners() external view returns (address[] memory);
+
+    // Credentials
+    // ~~~~~~~~~~~~~~~~~~
+
+    function getCouncilToken() external view returns (address);
+
+    function getCouncilMembers() external view returns (address[] memory);
+}
diff --git a/packages/core-modules/contracts/modules/ElectionModule.sol b/packages/core-modules/contracts/modules/ElectionModule.sol
new file mode 100644
index 00000000..afddcc2e
--- /dev/null
+++ b/packages/core-modules/contracts/modules/ElectionModule.sol
@@ -0,0 +1,350 @@
+//SPDX-License-Identifier: MIT
+pragma solidity ^0.8.0;
+
+import "@synthetixio/core-contracts/contracts/errors/InitError.sol";
+import "@synthetixio/core-contracts/contracts/ownership/OwnableMixin.sol";
+import "../interfaces/IElectionModule.sol";
+import "../submodules/election/ElectionSchedule.sol";
+import "../submodules/election/ElectionCredentials.sol";
+import "../submodules/election/strategies/ElectionVotesBasic.sol";
+import "../submodules/election/strategies/ElectionTallyPlurality.sol";
+
+/// @title Module for electing a council, represented by a set of NFT holders
+/// @dev This contract contains all election related external functions. Inherited code only provides encapsulated internal logic.
+contract ElectionModule is
+    IElectionModule,
+    ElectionSchedule,
+    ElectionCredentials,
+    ElectionVotesBasic,
+    ElectionTallyPlurality,
+    OwnableMixin
+{
+    using SetUtil for SetUtil.AddressSet;
+
+    /// @notice Initializes the module and immediately starts the first epoch with the owner as the single council member
+    function initializeElectionModule(
+        string memory councilTokenName,
+        string memory councilTokenSymbol,
+        uint64 nominationPeriodStartDate,
+        uint64 votingPeriodStartDate,
+        uint64 epochEndDate
+    ) external override onlyOwner onlyIfNotInitialized {
+        ElectionStore storage store = _electionStore();
+
+        ElectionSettings storage settings = _electionSettings();
+        settings.minNominationPeriodDuration = 2 days;
+        settings.minVotingPeriodDuration = 2 days;
+        settings.minEpochDuration = 7 days;
+        settings.maxDateAdjustmentTolerance = 7 days;
+        settings.nextEpochSeatCount = 3;
+        settings.defaultBallotEvaluationBatchSize = 500;
+
+        store.currentEpochIndex = 1;
+
+        EpochData storage firstEpoch = _getEpochAtPosition(1);
+        uint64 epochStartDate = uint64(block.timestamp);
+        _configureEpochSchedule(firstEpoch, epochStartDate, nominationPeriodStartDate, votingPeriodStartDate, epochEndDate);
+
+        _createCouncilToken(councilTokenName, councilTokenSymbol);
+        _addCouncilMember(msg.sender);
+
+        store.initialized = true;
+
+        emit ElectionModuleInitialized();
+        emit EpochStarted(1);
+    }
+
+    /// @notice Shows whether the module has been initialized
+    function isElectionModuleInitialized() external view override returns (bool) {
+        return _isInitialized();
+    }
+
+    /// @notice Upgrades the implementation of the existing council NFT token
+    function upgradeCouncilToken(address newCouncilTokenImplementation) external override onlyOwner onlyIfInitialized {
+        CouncilToken(getCouncilToken()).upgradeTo(newCouncilTokenImplementation);
+
+        emit CouncilTokenUpgraded(newCouncilTokenImplementation);
+    }
+
+    /// @notice Adjust the current epoch schedule requiring that the current period remains Idle, and that changes are small (see setMaxDateAdjustmentTolerance)
+    function tweakEpochSchedule(
+        uint64 newNominationPeriodStartDate,
+        uint64 newVotingPeriodStartDate,
+        uint64 newEpochEndDate
+    ) external override onlyOwner onlyInPeriod(ElectionPeriod.Idle) {
+        _adjustEpochSchedule(
+            _getCurrentEpoch(),
+            newNominationPeriodStartDate,
+            newVotingPeriodStartDate,
+            newEpochEndDate,
+            true /*ensureChangesAreSmall = true*/
+        );
+
+        emit EpochScheduleUpdated(newNominationPeriodStartDate, newVotingPeriodStartDate, newEpochEndDate);
+    }
+
+    /// @notice Adjusts the current epoch schedule requiring that the current period remains Idle
+    function modifyEpochSchedule(
+        uint64 newNominationPeriodStartDate,
+        uint64 newVotingPeriodStartDate,
+        uint64 newEpochEndDate
+    ) external override onlyOwner onlyInPeriod(ElectionPeriod.Idle) {
+        _adjustEpochSchedule(
+            _getCurrentEpoch(),
+            newNominationPeriodStartDate,
+            newVotingPeriodStartDate,
+            newEpochEndDate,
+            false /*!ensureChangesAreSmall = false*/
+        );
+
+        emit EpochScheduleUpdated(newNominationPeriodStartDate, newVotingPeriodStartDate, newEpochEndDate);
+    }
+
+    /// @notice Determines minimum values for epoch schedule adjustments
+    function setMinEpochDurations(
+        uint64 newMinNominationPeriodDuration,
+        uint64 newMinVotingPeriodDuration,
+        uint64 newMinEpochDuration
+    ) external override onlyOwner {
+        _setMinEpochDurations(newMinNominationPeriodDuration, newMinVotingPeriodDuration, newMinEpochDuration);
+
+        emit MinimumEpochDurationsChanged(newMinNominationPeriodDuration, newMinVotingPeriodDuration, newMinEpochDuration);
+    }
+
+    /// @notice Determines adjustment size for tweakEpochSchedule
+    function setMaxDateAdjustmentTolerance(uint64 newMaxDateAdjustmentTolerance) external override onlyOwner {
+        if (newMaxDateAdjustmentTolerance == 0) revert InvalidElectionSettings();
+
+        _electionSettings().maxDateAdjustmentTolerance = newMaxDateAdjustmentTolerance;
+
+        emit MaxDateAdjustmentToleranceChanged(newMaxDateAdjustmentTolerance);
+    }
+
+    /// @notice Determines batch size when evaluate() is called with numBallots = 0
+    function setDefaultBallotEvaluationBatchSize(uint newDefaultBallotEvaluationBatchSize) external override onlyOwner {
+        if (newDefaultBallotEvaluationBatchSize == 0) revert InvalidElectionSettings();
+
+        _electionSettings().defaultBallotEvaluationBatchSize = newDefaultBallotEvaluationBatchSize;
+
+        emit DefaultBallotEvaluationBatchSizeChanged(newDefaultBallotEvaluationBatchSize);
+    }
+
+    /// @notice Determines the number of council members in the next epoch
+    function setNextEpochSeatCount(uint8 newSeatCount) external override onlyOwner onlyInPeriod(ElectionPeriod.Idle) {
+        if (newSeatCount == 0) revert InvalidElectionSettings();
+
+        _electionSettings().nextEpochSeatCount = newSeatCount;
+
+        emit NextEpochSeatCountChanged(newSeatCount);
+    }
+
+    /// @notice Allows anyone to self-nominate during the Nomination period
+    function nominate() external override onlyInPeriod(ElectionPeriod.Nomination) {
+        SetUtil.AddressSet storage nominees = _getCurrentElection().nominees;
+
+        if (nominees.contains(msg.sender)) revert AlreadyNominated();
+
+        nominees.add(msg.sender);
+
+        emit CandidateNominated(msg.sender);
+    }
+
+    /// @notice Self-withdrawal of nominations during the Nomination period
+    function withdrawNomination() external override onlyInPeriod(ElectionPeriod.Nomination) {
+        SetUtil.AddressSet storage nominees = _getCurrentElection().nominees;
+
+        if (!nominees.contains(msg.sender)) revert NotNominated();
+
+        nominees.remove(msg.sender);
+
+        emit NominationWithdrawn(msg.sender);
+    }
+
+    /// @notice Allows anyone with vote power to vote on nominated candidates during the Voting period
+    /// @dev ElectionVotes needs to be extended to specify what determines voting power
+    function elect(address[] calldata candidates) external override onlyInPeriod(ElectionPeriod.Vote) {
+        uint votePower = _getVotePower(msg.sender);
+
+        if (votePower == 0) revert NoVotePower();
+
+        _validateCandidates(candidates);
+
+        bytes32 ballotId;
+
+        if (hasVoted(msg.sender)) {
+            ballotId = _withdrawVote(msg.sender, votePower);
+
+            emit VoteWithdrawn(msg.sender, ballotId, votePower);
+        }
+
+        ballotId = _recordVote(msg.sender, votePower, candidates);
+
+        emit VoteRecorded(msg.sender, ballotId, votePower);
+    }
+
+    /// @notice Processes ballots in batches during the Evaluation period (after epochEndDate)
+    /// @dev ElectionTally needs to be extended to specify how votes are counted
+    function evaluate(uint numBallots) external override onlyInPeriod(ElectionPeriod.Evaluation) {
+        if (isElectionEvaluated()) revert ElectionAlreadyEvaluated();
+
+        _evaluateNextBallotBatch(numBallots);
+
+        ElectionStore storage store = _electionStore();
+        ElectionData storage election = _getCurrentElection();
+
+        uint totalBallots = election.ballotIds.length;
+        if (election.numEvaluatedBallots < totalBallots) {
+            emit ElectionBatchEvaluated(store.currentEpochIndex, election.numEvaluatedBallots, totalBallots);
+        } else {
+            election.evaluated = true;
+
+            emit ElectionEvaluated(store.currentEpochIndex, totalBallots);
+        }
+    }
+
+    /// @notice Shuffles NFTs and resolves an election after it has been evaluated
+    /// @dev Burns previous NFTs and mints new ones
+    function resolve() external override onlyInPeriod(ElectionPeriod.Evaluation) {
+        if (!isElectionEvaluated()) revert ElectionNotEvaluated();
+
+        _removeAllCouncilMembers();
+        _addCouncilMembers(_getCurrentElection().winners);
+
+        _getCurrentElection().resolved = true;
+
+        _copyCurrentEpochScheduleToNextEpoch();
+
+        ElectionStore storage store = _electionStore();
+
+        uint newEpochIndex = store.currentEpochIndex + 1;
+        store.currentEpochIndex = newEpochIndex;
+
+        emit EpochStarted(newEpochIndex);
+    }
+
+    /// @notice Exposes minimum durations required when adjusting epoch schedules
+    function getMinEpochDurations()
+        external
+        view
+        override
+        returns (
+            uint64 minNominationPeriodDuration,
+            uint64 minVotingPeriodDuration,
+            uint64 minEpochDuration
+        )
+    {
+        ElectionSettings storage settings = _electionSettings();
+
+        return (settings.minNominationPeriodDuration, settings.minVotingPeriodDuration, settings.minEpochDuration);
+    }
+
+    /// @notice Exposes maximum size of adjustments when calling tweakEpochSchedule
+    function getMaxDateAdjustmenTolerance() external view override returns (uint64) {
+        return _electionSettings().maxDateAdjustmentTolerance;
+    }
+
+    /// @notice Shows the default batch size when calling evaluate() with numBallots = 0
+    function getDefaultBallotEvaluationBatchSize() external view override returns (uint) {
+        return _electionSettings().defaultBallotEvaluationBatchSize;
+    }
+
+    /// @notice Shows the number of council members that the next epoch will have
+    function getNextEpochSeatCount() external view override returns (uint8) {
+        return _electionSettings().nextEpochSeatCount;
+    }
+
+    /// @notice Returns the index of the current epoch. The first epoch's index is 1
+    function getEpochIndex() external view override returns (uint) {
+        return _electionStore().currentEpochIndex;
+    }
+
+    /// @notice Returns the date in which the current epoch started
+    function getEpochStartDate() external view override returns (uint64) {
+        return _getCurrentEpoch().startDate;
+    }
+
+    /// @notice Returns the date in which the current epoch will end
+    function getEpochEndDate() external view override returns (uint64) {
+        return _getCurrentEpoch().endDate;
+    }
+
+    /// @notice Returns the date in which the Nomination period in the current epoch will start
+    function getNominationPeriodStartDate() external view override returns (uint64) {
+        return _getCurrentEpoch().nominationPeriodStartDate;
+    }
+
+    /// @notice Returns the date in which the Voting period in the current epoch will start
+    function getVotingPeriodStartDate() external view override returns (uint64) {
+        return _getCurrentEpoch().votingPeriodStartDate;
+    }
+
+    /// @notice Returns the current period type: Idle, Nomination, Voting, Evaluation
+    function getCurrentPeriod() external view override returns (uint) {
+        return uint(_getCurrentPeriod());
+    }
+
+    /// @notice Shows if a candidate has been nominated in the current epoch
+    function isNominated(address candidate) external view override returns (bool) {
+        return _getCurrentElection().nominees.contains(candidate);
+    }
+
+    /// @notice Returns a list of all nominated candidates in the current epoch
+    function getNominees() external view override returns (address[] memory) {
+        return _getCurrentElection().nominees.values();
+    }
+
+    /// @notice Hashes a list of candidates (used for identifying and storing ballots)
+    function calculateBallotId(address[] calldata candidates) external pure override returns (bytes32) {
+        return _calculateBallotId(candidates);
+    }
+
+    /// @notice Returns the ballot id that voter voted on in the current election
+    function getBallotVoted(address voter) public view override returns (bytes32) {
+        return _getCurrentElection().ballotIdsByAddress[voter];
+    }
+
+    /// @notice Returns of voter has voted in the current election
+    function hasVoted(address voter) public view override returns (bool) {
+        return getBallotVoted(voter) != bytes32(0);
+    }
+
+    /// @notice Returns the vote power of voter in the current election
+    function getVotePower(address voter) external view override returns (uint) {
+        return _getVotePower(voter);
+    }
+
+    /// @notice Returns the number of votes given to a particular ballot
+    function getBallotVotes(bytes32 ballotId) external view override returns (uint) {
+        return _getBallot(ballotId).votes;
+    }
+
+    /// @notice Returns the list of candidates that a particular ballot has
+    function getBallotCandidates(bytes32 ballotId) external view override returns (address[] memory) {
+        return _getBallot(ballotId).candidates;
+    }
+
+    /// @notice Returns whether all ballots in the current election have been counted
+    function isElectionEvaluated() public view override returns (bool) {
+        return _getCurrentElection().evaluated;
+    }
+
+    /// @notice Returns the number of votes a candidate received. Requires the election to be partially or totally evaluated
+    function getCandidateVotes(address candidate) external view override returns (uint) {
+        return _getCurrentElection().candidateVotes[candidate];
+    }
+
+    /// @notice Returns the winners of the current election. Requires the election to be partially or totally evaluated
+    function getElectionWinners() external view override returns (address[] memory) {
+        return _getCurrentElection().winners.values();
+    }
+
+    /// @notice Returns the address of the council NFT token
+    function getCouncilToken() public view override returns (address) {
+        return _electionStore().councilToken;
+    }
+
+    /// @notice Returns the current NFT token holders
+    function getCouncilMembers() external view override returns (address[] memory) {
+        return _electionStore().councilMembers.values();
+    }
+}
diff --git a/packages/core-modules/contracts/storage/ElectionStorage.sol b/packages/core-modules/contracts/storage/ElectionStorage.sol
new file mode 100644
index 00000000..218728cf
--- /dev/null
+++ b/packages/core-modules/contracts/storage/ElectionStorage.sol
@@ -0,0 +1,92 @@
+//SPDX-License-Identifier: MIT
+pragma solidity ^0.8.0;
+
+import "@synthetixio/core-contracts/contracts/utils/SetUtil.sol";
+
+contract ElectionStorage {
+    struct ElectionStore {
+        // True if initializeElectionModule was called
+        bool initialized;
+        // Current epoch index
+        // First epoch is 1
+        uint currentEpochIndex;
+        // 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;
+        // EpochData's by epoch index
+        mapping(uint => EpochData) epochs;
+        // ElectionData's by epoch index
+        mapping(uint => ElectionData) elections;
+        // Pointer to ElectionSettings
+        // To be always used via store.settings[0]
+        mapping(uint => ElectionSettings) settings;
+    }
+
+    struct ElectionSettings {
+        // Number of council members in the next epoch
+        uint8 nextEpochSeatCount;
+        // 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;
+        // Maximum size for tweaking epoch schedules (see tweakEpochSchedule)
+        uint64 maxDateAdjustmentTolerance;
+        // Default batch size when calling evaluate() with numBallots = 0
+        uint defaultBallotEvaluationBatchSize;
+    }
+
+    struct EpochData {
+        // 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;
+    }
+
+    struct ElectionData {
+        // True if ballots have been counted in this election
+        bool evaluated;
+        // True if NFTs have been re-shuffled in this election
+        bool resolved;
+        // Number of counted ballots in this election
+        uint numEvaluatedBallots;
+        // List of nominated candidates in this election
+        SetUtil.AddressSet nominees;
+        // 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 => BallotData) ballotsById;
+        // Ballot id that each user voted on
+        mapping(address => bytes32) ballotIdsByAddress;
+        // Number of votes for each candidate
+        mapping(address => uint) candidateVotes;
+    }
+
+    struct BallotData {
+        // Total accumulated votes in this ballot (needs evaluation)
+        uint votes;
+        // List of candidates in this ballot
+        address[] candidates;
+    }
+
+    function _electionSettings() internal view returns (ElectionSettings storage) {
+        return _electionStore().settings[0];
+    }
+
+    function _electionStore() internal pure returns (ElectionStore storage store) {
+        assembly {
+            // bytes32(uint(keccak256("io.synthetix.election")) - 1)
+            store.slot := 0x4a7bae7406c7467d50a80c6842d6ba8287c729469098e48fc594351749ba4b22
+        }
+    }
+}
diff --git a/packages/core-modules/contracts/submodules/election/ElectionBase.sol b/packages/core-modules/contracts/submodules/election/ElectionBase.sol
new file mode 100644
index 00000000..9bae9c33
--- /dev/null
+++ b/packages/core-modules/contracts/submodules/election/ElectionBase.sol
@@ -0,0 +1,107 @@
+//SPDX-License-Identifier: MIT
+pragma solidity ^0.8.0;
+
+import "@synthetixio/core-contracts/contracts/initializable/InitializableMixin.sol";
+import "../../storage/ElectionStorage.sol";
+
+/// @dev Common utils, errors, and events to be used by any contracts that conform the ElectionModule
+contract ElectionBase is ElectionStorage, InitializableMixin {
+    // ---------------------------------------
+    // Enums
+    // ---------------------------------------
+
+    enum ElectionPeriod {
+        // Council elected and active
+        Idle,
+        // Accepting nominations for next election
+        Nomination,
+        // Accepting votes for ongoing election
+        Vote,
+        // Votes being counted
+        Evaluation
+    }
+
+    // ---------------------------------------
+    // Errors
+    // ---------------------------------------
+
+    error ElectionNotEvaluated();
+    error ElectionAlreadyEvaluated();
+    error AlreadyNominated();
+    error NotNominated();
+    error NoCandidates();
+    error NoVotePower();
+    error DuplicateCandidates();
+    error InvalidEpochConfiguration();
+    error InvalidElectionSettings();
+    error NotCallableInCurrentPeriod();
+    error ChangesCurrentPeriod();
+    error AlreadyACouncilMember();
+    error NotACouncilMember();
+
+    // ---------------------------------------
+    // Events
+    // ---------------------------------------
+
+    event ElectionModuleInitialized();
+    event EpochStarted(uint epochIndex);
+    event CouncilTokenCreated(address proxy, address implementation);
+    event CouncilTokenUpgraded(address newImplementation);
+    event CouncilMemberAdded(address member);
+    event CouncilMemberRemoved(address member);
+    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(uint seatCount);
+    event CandidateNominated(address indexed candidate);
+    event NominationWithdrawn(address indexed candidate);
+    event VoteRecorded(address indexed voter, bytes32 indexed ballotId, uint votePower);
+    event VoteWithdrawn(address indexed voter, bytes32 indexed ballotId, uint votePower);
+    event ElectionEvaluated(uint epochIndex, uint totalBallots);
+    event ElectionBatchEvaluated(uint epochIndex, uint evaluatedBallots, uint totalBallots);
+
+    // ---------------------------------------
+    // Helpers
+    // ---------------------------------------
+
+    function _isInitialized() internal view override returns (bool) {
+        return _electionStore().initialized;
+    }
+
+    function _getCurrentEpoch() internal view returns (EpochData storage) {
+        return _getEpochAtPosition(_electionStore().currentEpochIndex);
+    }
+
+    function _getNextEpoch() internal view returns (EpochData storage) {
+        return _getEpochAtPosition(_electionStore().currentEpochIndex + 1);
+    }
+
+    function _getEpochAtPosition(uint position) internal view returns (EpochData storage) {
+        return _electionStore().epochs[position];
+    }
+
+    function _getCurrentElection() internal view returns (ElectionData storage) {
+        return _getElectionAtPosition(_electionStore().currentEpochIndex);
+    }
+
+    function _getElectionAtPosition(uint position) internal view returns (ElectionData storage) {
+        return _electionStore().elections[position];
+    }
+
+    function _getBallot(bytes32 ballotId) internal view returns (BallotData storage) {
+        return _getCurrentElection().ballotsById[ballotId];
+    }
+
+    function _calculateBallotId(address[] memory candidates) internal pure returns (bytes32) {
+        return keccak256(abi.encodePacked(candidates));
+    }
+
+    function _ballotExists(BallotData storage ballot) internal view returns (bool) {
+        return ballot.candidates.length != 0;
+    }
+}
diff --git a/packages/core-modules/contracts/submodules/election/ElectionCredentials.sol b/packages/core-modules/contracts/submodules/election/ElectionCredentials.sol
new file mode 100644
index 00000000..0db3381a
--- /dev/null
+++ b/packages/core-modules/contracts/submodules/election/ElectionCredentials.sol
@@ -0,0 +1,98 @@
+//SPDX-License-Identifier: MIT
+pragma solidity ^0.8.0;
+
+import "@synthetixio/core-contracts/contracts/proxy/UUPSProxy.sol";
+import "../../tokens/CouncilToken.sol";
+import "./ElectionBase.sol";
+
+/// @dev Core functionality for keeping track of council members with an NFT token
+contract ElectionCredentials is ElectionBase {
+    using SetUtil for SetUtil.AddressSet;
+
+    function _createCouncilToken(string memory tokenName, string memory tokenSymbol) internal {
+        CouncilToken implementation = new CouncilToken();
+
+        UUPSProxy proxy = new UUPSProxy(address(implementation));
+
+        CouncilToken token = CouncilToken(address(proxy));
+
+        token.nominateNewOwner(address(this));
+        token.acceptOwnership();
+
+        token.initialize(tokenName, tokenSymbol);
+
+        _electionStore().councilToken = address(token);
+
+        emit CouncilTokenCreated(address(proxy), address(implementation));
+    }
+
+    function _addCouncilMembers(SetUtil.AddressSet storage newMembers) internal {
+        uint numNewMembers = newMembers.length();
+
+        for (uint newMemberPosition = 1; newMemberPosition <= numNewMembers; newMemberPosition++) {
+            _addCouncilMember(newMembers.valueAt(newMemberPosition));
+        }
+    }
+
+    function _removeAllCouncilMembers() internal {
+        SetUtil.AddressSet storage members = _electionStore().councilMembers;
+
+        uint numMembers = members.length();
+
+        for (uint memberIndex = 0; memberIndex < numMembers; memberIndex++) {
+            // Always removes the first element in the array
+            // until none are left.
+            _removeCouncilMember(members.valueAt(1));
+        }
+    }
+
+    function _addCouncilMember(address newMember) internal {
+        ElectionStore storage store = _electionStore();
+        SetUtil.AddressSet storage members = store.councilMembers;
+
+        if (members.contains(newMember)) {
+            revert AlreadyACouncilMember();
+        }
+
+        members.add(newMember);
+
+        // Note that tokenId = 0 will not be used.
+        uint tokenId = members.length();
+        _getCouncilToken().mint(newMember, tokenId);
+
+        store.councilTokenIds[newMember] = tokenId;
+
+        emit CouncilMemberAdded(newMember);
+    }
+
+    function _removeCouncilMember(address member) internal {
+        ElectionStore storage store = _electionStore();
+        SetUtil.AddressSet storage members = store.councilMembers;
+
+        if (!members.contains(member)) {
+            revert NotACouncilMember();
+        }
+
+        members.remove(member);
+
+        uint tokenId = _getCouncilMemberTokenId(member);
+        _getCouncilToken().burn(tokenId);
+
+        // tokenId = 0 means no associated token.
+        store.councilTokenIds[member] = 0;
+
+        emit CouncilMemberRemoved(member);
+    }
+
+    function _getCouncilToken() private view returns (CouncilToken) {
+        return CouncilToken(_electionStore().councilToken);
+    }
+
+    function _getCouncilMemberTokenId(address member) private view returns (uint) {
+        uint tokenId = _electionStore().councilTokenIds[member];
+
+        if (tokenId == 0) revert NotACouncilMember();
+
+        return tokenId;
+    }
+}
diff --git a/packages/core-modules/contracts/submodules/election/ElectionSchedule.sol b/packages/core-modules/contracts/submodules/election/ElectionSchedule.sol
new file mode 100644
index 00000000..ce78eb2d
--- /dev/null
+++ b/packages/core-modules/contracts/submodules/election/ElectionSchedule.sol
@@ -0,0 +1,174 @@
+//SPDX-License-Identifier: MIT
+pragma solidity ^0.8.0;
+
+import "./ElectionBase.sol";
+
+/// @dev Provides core schedule functionality. I.e. dates, periods, etc
+contract ElectionSchedule is ElectionBase {
+    /// @dev Used to allow certain functions to only operate within a given period
+    modifier onlyInPeriod(ElectionPeriod period) {
+        if (_getCurrentPeriod() != period) {
+            revert NotCallableInCurrentPeriod();
+        }
+
+        _;
+    }
+
+    /// @dev Determines the current period type according to the current time and the epoch's dates
+    function _getCurrentPeriod() internal view returns (ElectionPeriod) {
+        EpochData storage epoch = _getCurrentEpoch();
+
+        uint64 currentTime = uint64(block.timestamp);
+
+        uint64 epochEndDate = epoch.endDate;
+        if (epochEndDate == 0) {
+            revert InvalidEpochConfiguration();
+        }
+
+        if (currentTime >= epochEndDate) {
+            return ElectionPeriod.Evaluation;
+        }
+
+        if (currentTime >= epoch.votingPeriodStartDate) {
+            return ElectionPeriod.Vote;
+        }
+
+        if (currentTime >= epoch.nominationPeriodStartDate) {
+            return ElectionPeriod.Nomination;
+        }
+
+        return ElectionPeriod.Idle;
+    }
+
+    /// @dev Sets dates within an epoch, with validations
+    function _configureEpochSchedule(
+        EpochData 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 storage settings = _electionSettings();
+
+        if (
+            epochDuration < settings.minEpochDuration ||
+            nominationPeriodDuration < settings.minNominationPeriodDuration ||
+            votingPeriodDuration < settings.minVotingPeriodDuration
+        ) {
+            revert InvalidEpochConfiguration();
+        }
+    }
+
+    /// @dev Changes epoch dates, with validations
+    function _adjustEpochSchedule(
+        EpochData storage epoch,
+        uint64 newNominationPeriodStartDate,
+        uint64 newVotingPeriodStartDate,
+        uint64 newEpochEndDate,
+        bool ensureChangesAreSmall
+    ) internal {
+        uint64 maxDateAdjustmentTolerance = _electionSettings().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 (_getCurrentPeriod() != ElectionPeriod.Idle) {
+            revert ChangesCurrentPeriod();
+        }
+    }
+
+    /// @dev Copies the current epoch schedule to the next epoch, maintaining durations
+    function _copyCurrentEpochScheduleToNextEpoch() internal {
+        EpochData storage currentEpoch = _getCurrentEpoch();
+        EpochData storage nextEpoch = _getNextEpoch();
+
+        uint64 nextEpochStartDate = uint64(block.timestamp);
+        uint64 nextEpochEndDate = nextEpochStartDate + _getEpochDuration(currentEpoch);
+        uint64 nextVotingPeriodStartDate = nextEpochEndDate - _getVotingPeriodDuration(currentEpoch);
+        uint64 nextNominationPeriodStartDate = nextVotingPeriodStartDate - _getNominationPeriodDuration(currentEpoch);
+
+        _configureEpochSchedule(
+            nextEpoch,
+            nextEpochStartDate,
+            nextNominationPeriodStartDate,
+            nextVotingPeriodStartDate,
+            nextEpochEndDate
+        );
+    }
+
+    /// @dev Sets the minimum epoch durations, with validations
+    function _setMinEpochDurations(
+        uint64 newMinNominationPeriodDuration,
+        uint64 newMinVotingPeriodDuration,
+        uint64 newMinEpochDuration
+    ) internal {
+        ElectionSettings storage settings = _electionSettings();
+
+        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(EpochData storage epoch) private view returns (uint64) {
+        return epoch.endDate - epoch.startDate;
+    }
+
+    function _getVotingPeriodDuration(EpochData storage epoch) private view returns (uint64) {
+        return epoch.endDate - epoch.votingPeriodStartDate;
+    }
+
+    function _getNominationPeriodDuration(EpochData storage epoch) private view returns (uint64) {
+        return epoch.votingPeriodStartDate - epoch.nominationPeriodStartDate;
+    }
+}
diff --git a/packages/core-modules/contracts/submodules/election/ElectionTally.sol b/packages/core-modules/contracts/submodules/election/ElectionTally.sol
new file mode 100644
index 00000000..5e100b53
--- /dev/null
+++ b/packages/core-modules/contracts/submodules/election/ElectionTally.sol
@@ -0,0 +1,48 @@
+//SPDX-License-Identifier: MIT
+pragma solidity ^0.8.0;
+
+import "./ElectionBase.sol";
+
+/// @dev Defines core vote-counting / ballot-processing functionality in ElectionModule.evaluate()
+abstract contract ElectionTally is ElectionBase {
+    function _evaluateNextBallotBatch(uint numBallots) internal {
+        if (numBallots == 0) {
+            numBallots = _electionSettings().defaultBallotEvaluationBatchSize;
+        }
+
+        ElectionData storage election = _getCurrentElection();
+        uint totalBallots = election.ballotIds.length;
+
+        uint firstBallotIndex = election.numEvaluatedBallots;
+
+        uint lastBallotIndex = firstBallotIndex + numBallots;
+        if (lastBallotIndex > totalBallots) {
+            lastBallotIndex = totalBallots;
+        }
+
+        _evaluateBallotRange(election, firstBallotIndex, lastBallotIndex);
+    }
+
+    function _evaluateBallotRange(
+        ElectionData storage election,
+        uint fromIndex,
+        uint toIndex
+    ) private {
+        ElectionSettings storage settings = _electionSettings();
+        uint numSeats = settings.nextEpochSeatCount;
+
+        for (uint ballotIndex = fromIndex; ballotIndex < toIndex; ballotIndex++) {
+            bytes32 ballotId = election.ballotIds[ballotIndex];
+            BallotData storage ballot = election.ballotsById[ballotId];
+
+            _evaluateBallot(election, ballot, numSeats);
+        }
+    }
+
+    /// @dev Override this function to specify how votes are counted
+    function _evaluateBallot(
+        ElectionData storage election,
+        BallotData storage ballot,
+        uint numSeats
+    ) internal virtual;
+}
diff --git a/packages/core-modules/contracts/submodules/election/ElectionVotes.sol b/packages/core-modules/contracts/submodules/election/ElectionVotes.sol
new file mode 100644
index 00000000..c5108ed0
--- /dev/null
+++ b/packages/core-modules/contracts/submodules/election/ElectionVotes.sol
@@ -0,0 +1,80 @@
+//SPDX-License-Identifier: MIT
+pragma solidity ^0.8.0;
+
+import "./ElectionBase.sol";
+import "@synthetixio/core-contracts/contracts/errors/ChangeError.sol";
+
+/// @dev Defines core functionality for recording votes in ElectionModule.elect()
+abstract contract ElectionVotes is ElectionBase {
+    using SetUtil for SetUtil.AddressSet;
+
+    function _validateCandidates(address[] calldata candidates) internal virtual {
+        uint length = candidates.length;
+
+        if (length == 0) {
+            revert NoCandidates();
+        }
+
+        SetUtil.AddressSet storage nominees = _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 voter,
+        uint votePower,
+        address[] calldata candidates
+    ) internal virtual returns (bytes32 ballotId) {
+        ElectionData storage election = _getCurrentElection();
+
+        ballotId = _calculateBallotId(candidates);
+        BallotData storage ballot = _getBallot(ballotId);
+
+        // Initialize ballot if new.
+        if (!_ballotExists(ballot)) {
+            address[] memory newCandidates = candidates;
+
+            ballot.candidates = newCandidates;
+
+            election.ballotIds.push(ballotId);
+        }
+
+        ballot.votes += votePower;
+        election.ballotIdsByAddress[voter] = ballotId;
+
+        return ballotId;
+    }
+
+    function _withdrawVote(address voter, uint votePower) internal virtual returns (bytes32 ballotId) {
+        ElectionData storage election = _getCurrentElection();
+
+        ballotId = election.ballotIdsByAddress[voter];
+        BallotData storage ballot = _getBallot(ballotId);
+
+        ballot.votes -= votePower;
+        election.ballotIdsByAddress[voter] = bytes32(0);
+
+        return ballotId;
+    }
+
+    /// @dev Override this function to specify how vote power is calculated
+    function _getVotePower(address) internal view virtual returns (uint);
+}
diff --git a/packages/core-modules/contracts/submodules/election/strategies/ElectionTallyPlurality.sol b/packages/core-modules/contracts/submodules/election/strategies/ElectionTallyPlurality.sol
new file mode 100644
index 00000000..443e8178
--- /dev/null
+++ b/packages/core-modules/contracts/submodules/election/strategies/ElectionTallyPlurality.sol
@@ -0,0 +1,86 @@
+//SPDX-License-Identifier: MIT
+pragma solidity ^0.8.0;
+
+import "../ElectionTally.sol";
+
+/// @dev Basic way of counting votes in ElectionModule.evaluate()
+contract ElectionTallyPlurality is ElectionTally {
+    using SetUtil for SetUtil.AddressSet;
+
+    function _evaluateBallot(
+        ElectionData storage election,
+        BallotData storage ballot,
+        uint numSeats
+    ) internal override {
+        uint ballotVotes = ballot.votes;
+        if (ballotVotes == 0) {
+            return;
+        }
+
+        uint numCandidates = ballot.candidates.length;
+        for (uint candidateIndex = 0; candidateIndex < numCandidates; candidateIndex++) {
+            address candidate = ballot.candidates[candidateIndex];
+
+            uint currentCandidateVotes = election.candidateVotes[candidate];
+            uint newCandidateVotes = currentCandidateVotes + ballotVotes;
+            election.candidateVotes[candidate] = newCandidateVotes;
+
+            _updateWinnerSet(election, candidate, newCandidateVotes, numSeats);
+        }
+
+        election.numEvaluatedBallots += 1;
+    }
+
+    function _updateWinnerSet(
+        ElectionData storage election,
+        address candidate,
+        uint candidateVotes,
+        uint numSeats
+    ) private {
+        SetUtil.AddressSet storage winners = election.winners;
+
+        // Already a winner?
+        if (winners.contains(candidate)) {
+            return;
+        }
+
+        // Just take first empty seat if
+        // the set is not complete yet.
+        if (winners.length() < numSeats) {
+            winners.add(candidate);
+
+            return;
+        }
+
+        // Otherwise, replace the winner with the least votes
+        // in the set.
+        (address leastVotedWinner, uint leastVotes) = _findWinnerWithLeastVotes(election, winners);
+
+        if (candidateVotes > leastVotes) {
+            winners.replace(leastVotedWinner, candidate);
+        }
+    }
+
+    function _findWinnerWithLeastVotes(ElectionData storage election, SetUtil.AddressSet storage winners)
+        private
+        view
+        returns (address leastVotedWinner, uint leastVotes)
+    {
+        leastVotes = type(uint).max;
+
+        uint numWinners = winners.length();
+
+        for (uint8 winnerPosition = 1; winnerPosition <= numWinners; winnerPosition++) {
+            address winner = winners.valueAt(winnerPosition);
+            uint winnerVotes = election.candidateVotes[winner];
+
+            if (winnerVotes < leastVotes) {
+                leastVotes = winnerVotes;
+
+                leastVotedWinner = winner;
+            }
+        }
+
+        return (leastVotedWinner, leastVotes);
+    }
+}
diff --git a/packages/core-modules/contracts/submodules/election/strategies/ElectionVotesBasic.sol b/packages/core-modules/contracts/submodules/election/strategies/ElectionVotesBasic.sol
new file mode 100644
index 00000000..009751cd
--- /dev/null
+++ b/packages/core-modules/contracts/submodules/election/strategies/ElectionVotesBasic.sol
@@ -0,0 +1,11 @@
+//SPDX-License-Identifier: MIT
+pragma solidity ^0.8.0;
+
+import "../ElectionVotes.sol";
+
+/// @dev Basic placeholder implementation of determining vote power in ElectionModule.elect() - do not use in production!
+contract ElectionVotesBasic is ElectionVotes {
+    function _getVotePower(address) internal view virtual override returns (uint) {
+        return 1;
+    }
+}
diff --git a/packages/core-modules/contracts/tokens/CouncilToken.sol b/packages/core-modules/contracts/tokens/CouncilToken.sol
new file mode 100644
index 00000000..d781bfee
--- /dev/null
+++ b/packages/core-modules/contracts/tokens/CouncilToken.sol
@@ -0,0 +1,43 @@
+//SPDX-License-Identifier: MIT
+pragma solidity ^0.8.0;
+
+import "@synthetixio/core-contracts/contracts/proxy/UUPSImplementation.sol";
+import "@synthetixio/core-contracts/contracts/ownership/Ownable.sol";
+import "@synthetixio/core-contracts/contracts/token/ERC721.sol";
+
+contract CouncilToken is Ownable, UUPSImplementation, ERC721 {
+    error TokenIsNotTransferable();
+
+    function initialize(string memory tokenName, string memory tokenSymbol) public onlyOwner {
+        _initialize(tokenName, tokenSymbol, "");
+    }
+
+    function upgradeTo(address newImplementation) public override onlyOwner {
+        _upgradeTo(newImplementation);
+    }
+
+    function mint(address to, uint256 tokenId) public virtual onlyOwner {
+        _mint(to, tokenId);
+    }
+
+    function burn(uint256 tokenId) public virtual onlyOwner {
+        _burn(tokenId);
+    }
+
+    function transferFrom(
+        address,
+        address,
+        uint256
+    ) public virtual override {
+        revert TokenIsNotTransferable();
+    }
+
+    function safeTransferFrom(
+        address,
+        address,
+        uint256,
+        bytes memory
+    ) public virtual override {
+        revert TokenIsNotTransferable();
+    }
+}
diff --git a/packages/core-modules/hardhat.config.js b/packages/core-modules/hardhat.config.js
index 7d7a2e82..3d6984bb 100644
--- a/packages/core-modules/hardhat.config.js
+++ b/packages/core-modules/hardhat.config.js
@@ -22,4 +22,7 @@ module.exports = {
   contractSizer: {
     strict: true,
   },
+  mocha: {
+    timeout: 120000,
+  },
 };
diff --git a/packages/deployer/internal/config-defaults.js b/packages/deployer/internal/config-defaults.js
index 34ebd22b..5bb2c59b 100644
--- a/packages/deployer/internal/config-defaults.js
+++ b/packages/deployer/internal/config-defaults.js
@@ -1,5 +1,9 @@
 module.exports = {
   proxyContract: 'Proxy',
+  // Helper function for selecting which public functions are going to be added
+  // to the main Router. By default filter out the ones added during instrumentation
+  // by solidity coverage
+  routerFunctionFilter: (fnName) => !fnName.startsWith('c_0x'),
   paths: {
     deployments: 'deployments',
     modules: 'modules',
diff --git a/packages/deployer/internal/contract-helper.js b/packages/deployer/internal/contract-helper.js
index 60a0cd4e..0a7b9b8a 100644
--- a/packages/deployer/internal/contract-helper.js
+++ b/packages/deployer/internal/contract-helper.js
@@ -25,7 +25,7 @@ async function getAllSelectors(contractFullyQualifiedNames) {
 
   for (const name of contractFullyQualifiedNames) {
     const { contractName, abi } = await hre.artifacts.readArtifact(name);
-    const selectors = await getSelectors(abi);
+    const selectors = await getSelectors(abi, hre.config.deployer.routerFunctionFilter);
 
     allSelectors.push(...selectors.map((s) => ({ ...s, contractName })));
   }
diff --git a/packages/deployer/internal/interface-ast-validator.js b/packages/deployer/internal/interface-ast-validator.js
index 69396b14..cdfac7d5 100644
--- a/packages/deployer/internal/interface-ast-validator.js
+++ b/packages/deployer/internal/interface-ast-validator.js
@@ -5,9 +5,10 @@ const {
 } = require('@synthetixio/core-js/utils/ast/finders');
 
 class InterfaceCoverageASTValidator {
-  constructor(contractFullyQualifiedNames, astNodes) {
+  constructor(contractFullyQualifiedNames, astNodes, visibleFunctionsFilter) {
     this.contractFullyQualifiedNames = contractFullyQualifiedNames;
     this.astNodes = astNodes;
+    this.visibleFunctionsFilter = visibleFunctionsFilter;
   }
 
   findFunctionsNotDefinedInInterfaces() {
@@ -15,11 +16,6 @@ class InterfaceCoverageASTValidator {
 
     for (const contractFullyQualifiedName of this.contractFullyQualifiedNames) {
       const visibleFunctions = this._findVisibleFunctions(contractFullyQualifiedName);
-
-      if (visibleFunctions.length === 0) {
-        continue;
-      }
-
       const interfacedFunctions = this._findInterfaceFunctions(contractFullyQualifiedName);
 
       for (const visibleFunction of visibleFunctions) {
@@ -39,7 +35,7 @@ class InterfaceCoverageASTValidator {
   _findVisibleFunctions(contractFullyQualifiedName) {
     const visibleFunctions = findFunctionNodes(contractFullyQualifiedName, this.astNodes)
       .filter((f) => f.visibility === 'public' || f.visibility === 'external')
-      .filter((f) => !f.name.startsWith('c_0x')); // Filter out coverage added functions
+      .filter((f) => this.visibleFunctionsFilter(f.name));
 
     return visibleFunctions;
   }
diff --git a/packages/deployer/subtasks/create-deployment.js b/packages/deployer/subtasks/create-deployment.js
index 9d252b98..08a8b794 100644
--- a/packages/deployer/subtasks/create-deployment.js
+++ b/packages/deployer/subtasks/create-deployment.js
@@ -5,7 +5,7 @@ const { subtask } = require('hardhat/config');
 
 const prompter = require('@synthetixio/core-js/utils/io/prompter');
 const relativePath = require('@synthetixio/core-js/utils/misc/relative-path');
-const getDate = require('@synthetixio/core-js/utils/misc/get-date');
+const { formatDate } = require('@synthetixio/core-js/utils/misc/dates');
 const { getDeploymentFolder, getAllDeploymentFiles } = require('../utils/deployments');
 const { SUBTASK_CREATE_DEPLOYMENT } = require('../task-names');
 
@@ -76,7 +76,7 @@ async function _createNewDeploymentFileIfNeeded({ deploymentFiles, deploymentsFo
   }
 
   // Get the date with format `YYYY-mm-dd`
-  const today = getDate();
+  const today = formatDate();
 
   // Calculate the next deployment number based on the previous one
   let number = '00';
diff --git a/packages/deployer/subtasks/validate-interfaces.js b/packages/deployer/subtasks/validate-interfaces.js
index d4753dec..fc4f276e 100644
--- a/packages/deployer/subtasks/validate-interfaces.js
+++ b/packages/deployer/subtasks/validate-interfaces.js
@@ -11,7 +11,11 @@ subtask(SUBTASK_VALIDATE_INTERFACES).setAction(async (_, hre) => {
     .filter(({ isModule }) => isModule)
     .map((c) => c.contractFullyQualifiedName);
   const astNodes = Object.values(hre.deployer.deployment.sources).map((val) => val.ast);
-  const validator = new InterfaceCoverageASTValidator(moduleFullyQualifiedNames, astNodes);
+  const validator = new InterfaceCoverageASTValidator(
+    moduleFullyQualifiedNames,
+    astNodes,
+    hre.config.deployer.routerFunctionFilter
+  );
 
   const errorsFound = validator.findFunctionsNotDefinedInInterfaces();
 


Test patch:
diff --git a/packages/core-js/test/utils/hardhat/rpc.test.js b/packages/core-js/test/utils/hardhat/rpc.test.js
index dcc2dff4..cb721ce4 100644
--- a/packages/core-js/test/utils/hardhat/rpc.test.js
+++ b/packages/core-js/test/utils/hardhat/rpc.test.js
@@ -73,7 +73,7 @@ describe('utils/hardhat/rpc.js', () => {
     });
   });
 
-  describe('when fastforwarding', () => {
+  describe('when calling fastForward', () => {
     before('clear spy history', () => {
       provider.send.resetHistory();
     });
diff --git a/packages/core-js/test/utils/misc/dates.test.js b/packages/core-js/test/utils/misc/dates.test.js
new file mode 100644
index 00000000..82875d4f
--- /dev/null
+++ b/packages/core-js/test/utils/misc/dates.test.js
@@ -0,0 +1,35 @@
+const { ok, equal, throws } = require('assert/strict');
+const { formatDate, getUnixTimestamp, daysToSeconds } = require('../../../utils/misc/dates');
+
+describe('utils/misc/dates.js', function () {
+  describe('formatDate', function () {
+    it('returns the date with the correct format', function () {
+      const result = formatDate();
+      ok(/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test(result));
+    });
+
+    it('returns the date with the correct format using a custom date', function () {
+      const expected = '2011-10-10';
+      const result = formatDate(new Date(Date.parse(expected)));
+      equal(result, expected);
+    });
+
+    it('throws an error when given an invalid date object', function () {
+      throws(function () {
+        formatDate('2020-12-12');
+      }, new Error('Invalid date given'));
+    });
+  });
+
+  describe('getUnixTimestamp', function () {
+    it('returns the current unix timestamp', async function () {
+      equal(getUnixTimestamp(), Math.floor(new Date().getTime() / 1000));
+    });
+  });
+
+  describe('daysToSeconds', function () {
+    it('returns the expected value', async function () {
+      equal(daysToSeconds(7), 60 * 60 * 24 * 7);
+    });
+  });
+});
diff --git a/packages/core-js/test/utils/misc/get-date.test.js b/packages/core-js/test/utils/misc/get-date.test.js
deleted file mode 100644
index aa588db0..00000000
--- a/packages/core-js/test/utils/misc/get-date.test.js
+++ /dev/null
@@ -1,21 +0,0 @@
-const { ok, equal, throws } = require('assert/strict');
-const getDate = require('../../../utils/misc/get-date');
-
-describe('utils/misc/get-date.js', function () {
-  it('returns the date with the correct format', function () {
-    const result = getDate();
-    ok(/[0-9]{4}-[0-9]{2}-[0-9]{2}/.test(result));
-  });
-
-  it('returns the date with the correct format using a custom date', function () {
-    const expected = '2011-10-10';
-    const result = getDate(new Date(Date.parse(expected)));
-    equal(result, expected);
-  });
-
-  it('throws an error when given an invalid date object', function () {
-    throws(function () {
-      getDate('2020-12-12');
-    }, new Error('Invalid date given'));
-  });
-});
diff --git a/packages/core-modules/test/contracts/modules/ElectionModule/Elect.test.js b/packages/core-modules/test/contracts/modules/ElectionModule/Elect.test.js
new file mode 100644
index 00000000..a84b001a
--- /dev/null
+++ b/packages/core-modules/test/contracts/modules/ElectionModule/Elect.test.js
@@ -0,0 +1,226 @@
+const { ethers } = hre;
+const assert = require('assert/strict');
+const assertBn = require('@synthetixio/core-js/utils/assertions/assert-bignumber');
+const assertRevert = require('@synthetixio/core-js/utils/assertions/assert-revert');
+const { getTime, fastForwardTo } = require('@synthetixio/core-js/utils/hardhat/rpc');
+const { daysToSeconds } = require('@synthetixio/core-js/utils/misc/dates');
+const { bootstrap } = require('@synthetixio/deployer/utils/tests');
+const initializer = require('../../../helpers/initializer');
+const { ElectionPeriod } = require('./helpers/election-helper');
+const { findEvent } = require('@synthetixio/core-js/utils/ethers/events');
+
+describe('ElectionModule (vote)', () => {
+  const { proxyAddress } = bootstrap(initializer);
+
+  let ElectionModule;
+
+  let candidate1, candidate2, candidate3, candidate4;
+  let voter1, voter2, voter3, voter4, voter5;
+
+  let ballot1, ballot2, ballot3;
+
+  let receipt;
+
+  before('identify signers', async () => {
+    const users = await ethers.getSigners();
+
+    [candidate1, candidate2, candidate3, candidate4] = users;
+    [, , , , voter1, voter2, voter3, voter4, voter5] = users;
+  });
+
+  before('identify modules', async () => {
+    ElectionModule = await ethers.getContractAt('ElectionModule', proxyAddress());
+  });
+
+  describe('when the 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(
+        'Spartan Council Token',
+        'SCT',
+        nominationPeriodStartDate,
+        votingPeriodStartDate,
+        epochEndDate
+      );
+    });
+
+    describe('when entering the nomiantion period', function () {
+      before('fast forward', async function () {
+        await fastForwardTo(await ElectionModule.getNominationPeriodStartDate(), ethers.provider);
+      });
+
+      it('shows that the current period is Nomination', async function () {
+        assertBn.equal(await ElectionModule.getCurrentPeriod(), ElectionPeriod.Nomination);
+      });
+
+      describe('when nominations exist', function () {
+        before('nominate', async function () {
+          await ElectionModule.connect(candidate1).nominate();
+          await ElectionModule.connect(candidate2).nominate();
+          await ElectionModule.connect(candidate3).nominate();
+          await ElectionModule.connect(candidate4).nominate();
+        });
+
+        describe('when entering the election period', function () {
+          before('fast forward', async function () {
+            await fastForwardTo(await ElectionModule.getVotingPeriodStartDate(), ethers.provider);
+          });
+
+          it('shows that the current period is Vote', async function () {
+            assertBn.equal(await ElectionModule.getCurrentPeriod(), ElectionPeriod.Vote);
+          });
+
+          describe('when issuing invalid votes', function () {
+            describe('when voting with zero candidates', function () {
+              it('reverts', async function () {
+                await assertRevert(ElectionModule.elect([]), 'NoCandidates');
+              });
+            });
+
+            describe('when voting candidates that are not nominated', function () {
+              it('reverts', async function () {
+                await assertRevert(
+                  ElectionModule.elect([candidate1.address, candidate2.address, voter1.address]),
+                  'NotNominated'
+                );
+              });
+            });
+
+            describe('when voting duplicate candidates', function () {
+              it('reverts', async function () {
+                await assertRevert(
+                  ElectionModule.elect([
+                    candidate1.address,
+                    candidate2.address,
+                    candidate1.address,
+                  ]),
+                  'DuplicateCandidates'
+                );
+              });
+            });
+          });
+
+          it('can retrieve user vote power', async function () {
+            assertBn.equal(await ElectionModule.getVotePower(voter1.address), 1);
+            assertBn.equal(await ElectionModule.getVotePower(voter2.address), 1);
+            assertBn.equal(await ElectionModule.getVotePower(voter3.address), 1);
+          });
+
+          describe('when issuing valid votes', function () {
+            before('form ballots', async function () {
+              ballot1 = {
+                candidates: [candidate1.address],
+                id: await ElectionModule.calculateBallotId([candidate1.address]),
+              };
+              ballot2 = {
+                candidates: [candidate2.address, candidate4.address],
+                id: await ElectionModule.calculateBallotId([
+                  candidate2.address,
+                  candidate4.address,
+                ]),
+              };
+              ballot3 = {
+                candidates: [candidate3.address],
+                id: await ElectionModule.calculateBallotId([candidate3.address]),
+              };
+            });
+
+            before('vote', async function () {
+              await ElectionModule.connect(voter1).elect(ballot1.candidates);
+              await ElectionModule.connect(voter2).elect(ballot2.candidates);
+              await ElectionModule.connect(voter3).elect(ballot1.candidates);
+              await ElectionModule.connect(voter4).elect(ballot1.candidates);
+            });
+
+            before('vote and record receipt', async function () {
+              const tx = await ElectionModule.connect(voter5).elect(ballot2.candidates);
+              receipt = await tx.wait();
+            });
+
+            it('emitted a VoteRecorded event', async function () {
+              const event = findEvent({ receipt, eventName: 'VoteRecorded' });
+
+              assert.ok(event);
+              assertBn.equal(event.args.voter, voter5.address);
+              assert.equal(event.args.ballotId, ballot2.id);
+              assertBn.equal(event.args.votePower, 1);
+            });
+
+            it('can retrieve the corresponding ballot that users voted on', async function () {
+              assert.equal(await ElectionModule.getBallotVoted(voter1.address), ballot1.id);
+              assert.equal(await ElectionModule.getBallotVoted(voter2.address), ballot2.id);
+              assert.equal(await ElectionModule.getBallotVoted(voter3.address), ballot1.id);
+              assert.equal(await ElectionModule.getBallotVoted(voter4.address), ballot1.id);
+              assert.equal(await ElectionModule.getBallotVoted(voter5.address), ballot2.id);
+            });
+
+            it('can retrieve ballot votes', async function () {
+              assertBn.equal(await ElectionModule.getBallotVotes(ballot1.id), 3);
+              assertBn.equal(await ElectionModule.getBallotVotes(ballot2.id), 2);
+              assertBn.equal(await ElectionModule.getBallotVotes(ballot3.id), 0);
+            });
+
+            it('can retrive ballot candidates', async function () {
+              assert.deepEqual(
+                await ElectionModule.getBallotCandidates(ballot1.id),
+                ballot1.candidates
+              );
+              assert.deepEqual(
+                await ElectionModule.getBallotCandidates(ballot2.id),
+                ballot2.candidates
+              );
+            });
+
+            it('will retrieve no candidates for ballots that where not voted on', async function () {
+              assert.deepEqual(await ElectionModule.getBallotCandidates(ballot3.id), []);
+            });
+
+            describe('when users change their vote', function () {
+              before('change vote', async function () {
+                const tx = await ElectionModule.connect(voter5).elect(ballot3.candidates);
+                receipt = await tx.wait();
+              });
+
+              it('emitted VoteWithdrawn and VoteRecorded events', async function () {
+                let event;
+
+                event = findEvent({ receipt, eventName: 'VoteWithdrawn' });
+                assert.ok(event);
+                assertBn.equal(event.args.voter, voter5.address);
+                assert.equal(event.args.ballotId, ballot2.id);
+                assertBn.equal(event.args.votePower, 1);
+
+                event = findEvent({ receipt, eventName: 'VoteRecorded' });
+                assert.ok(event);
+                assertBn.equal(event.args.voter, voter5.address);
+                assert.equal(event.args.ballotId, ballot3.id);
+                assertBn.equal(event.args.votePower, 1);
+              });
+
+              it('can retrieve the corresponding ballot that users voted on', async function () {
+                assert.equal(await ElectionModule.getBallotVoted(voter5.address), ballot3.id);
+              });
+
+              it('can retrieve ballot votes', async function () {
+                assertBn.equal(await ElectionModule.getBallotVotes(ballot1.id), 3);
+                assertBn.equal(await ElectionModule.getBallotVotes(ballot2.id), 1);
+                assertBn.equal(await ElectionModule.getBallotVotes(ballot3.id), 1);
+              });
+
+              it('can retrive ballot candidates', async function () {
+                assert.deepEqual(
+                  await ElectionModule.getBallotCandidates(ballot3.id),
+                  ballot3.candidates
+                );
+              });
+            });
+          });
+        });
+      });
+    });
+  });
+});
diff --git a/packages/core-modules/test/contracts/modules/ElectionModule/Evaluate.test.js b/packages/core-modules/test/contracts/modules/ElectionModule/Evaluate.test.js
new file mode 100644
index 00000000..439e77ea
--- /dev/null
+++ b/packages/core-modules/test/contracts/modules/ElectionModule/Evaluate.test.js
@@ -0,0 +1,225 @@
+const { ethers } = hre;
+const assert = require('assert/strict');
+const assertBn = require('@synthetixio/core-js/utils/assertions/assert-bignumber');
+const assertRevert = require('@synthetixio/core-js/utils/assertions/assert-revert');
+const { getTime, fastForwardTo } = require('@synthetixio/core-js/utils/hardhat/rpc');
+const { daysToSeconds } = require('@synthetixio/core-js/utils/misc/dates');
+const { bootstrap } = require('@synthetixio/deployer/utils/tests');
+const initializer = require('../../../helpers/initializer');
+const { ElectionPeriod } = require('./helpers/election-helper');
+const { findEvent } = require('@synthetixio/core-js/utils/ethers/events');
+
+describe('ElectionModule (evaluate)', () => {
+  const { proxyAddress } = bootstrap(initializer);
+
+  let ElectionModule;
+
+  let epochIndexBefore;
+
+  let candidate1, candidate2, candidate3, candidate4, candidate5;
+  let voter1, voter2, voter3, voter4, voter5, voter6, voter7, voter8, voter9, voter10;
+
+  let ballot1, ballot2, ballot3;
+
+  let receipt;
+
+  before('identify signers', async () => {
+    const users = await ethers.getSigners();
+
+    [candidate1, candidate2, candidate3, candidate4, candidate5] = users;
+    [, , , , , voter1, voter2, voter3, voter4, voter5, voter6, voter7, voter8, voter9, voter10] =
+      users;
+  });
+
+  before('identify modules', async () => {
+    ElectionModule = await ethers.getContractAt('ElectionModule', proxyAddress());
+  });
+
+  describe('when the 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(
+        'Spartan Council Token',
+        'SCT',
+        nominationPeriodStartDate,
+        votingPeriodStartDate,
+        epochEndDate
+      );
+    });
+
+    describe('when entering the nomiantion period', function () {
+      before('fast forward', async function () {
+        await fastForwardTo(await ElectionModule.getNominationPeriodStartDate(), ethers.provider);
+      });
+
+      before('nominate', async function () {
+        await ElectionModule.connect(candidate1).nominate();
+        await ElectionModule.connect(candidate2).nominate();
+        await ElectionModule.connect(candidate3).nominate();
+        await ElectionModule.connect(candidate4).nominate();
+        await ElectionModule.connect(candidate5).nominate();
+      });
+
+      it('shows that nominations exist', async function () {
+        assertBn.equal((await ElectionModule.getNominees()).length, 5);
+      });
+
+      describe('when entering the election period', function () {
+        before('fast forward', async function () {
+          await fastForwardTo(await ElectionModule.getVotingPeriodStartDate(), ethers.provider);
+        });
+
+        before('form ballots', async function () {
+          ballot1 = {
+            candidates: [candidate2.address, candidate1.address],
+            id: await ElectionModule.calculateBallotId([candidate2.address, candidate1.address]),
+          };
+          ballot2 = {
+            candidates: [candidate3.address],
+            id: await ElectionModule.calculateBallotId([candidate3.address]),
+          };
+          ballot3 = {
+            candidates: [candidate5.address],
+            id: await ElectionModule.calculateBallotId([candidate5.address]),
+          };
+        });
+
+        before('vote', async function () {
+          await ElectionModule.connect(voter1).elect(ballot1.candidates);
+          await ElectionModule.connect(voter2).elect(ballot1.candidates);
+          await ElectionModule.connect(voter3).elect(ballot1.candidates);
+          await ElectionModule.connect(voter4).elect(ballot1.candidates);
+          await ElectionModule.connect(voter5).elect(ballot2.candidates);
+          await ElectionModule.connect(voter6).elect(ballot3.candidates);
+          await ElectionModule.connect(voter7).elect(ballot3.candidates);
+          await ElectionModule.connect(voter8).elect(ballot3.candidates);
+          await ElectionModule.connect(voter9).elect(ballot3.candidates);
+          await ElectionModule.connect(voter10).elect(ballot3.candidates);
+        });
+
+        it('shows that ballots were registered', async function () {
+          assertBn.equal(await ElectionModule.getBallotVotes(ballot1.id), 4);
+          assertBn.equal(await ElectionModule.getBallotVotes(ballot2.id), 1);
+          assertBn.equal(await ElectionModule.getBallotVotes(ballot3.id), 5);
+        });
+
+        describe('when entering the evaluation period', 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('before evaluating the epoch', function () {
+            describe('when trying to resolve the epoch', function () {
+              it('reverts', async function () {
+                await assertRevert(ElectionModule.resolve(), 'ElectionNotEvaluated');
+              });
+            });
+          });
+
+          describe('when evaluating the epoch', function () {
+            describe('partially', function () {
+              before('evaluate', async function () {
+                const tx = await ElectionModule.evaluate(1);
+                receipt = await tx.wait();
+              });
+
+              it('emitted an ElectionBatchEvaluated event', async function () {
+                const event = findEvent({ receipt, eventName: 'ElectionBatchEvaluated' });
+
+                assert.ok(event);
+                assertBn.equal(event.args.epochIndex, 1);
+                assertBn.equal(event.args.evaluatedBallots, 1);
+                assertBn.equal(event.args.totalBallots, 3);
+              });
+
+              it('shows that the epoch is not evaluated', async function () {
+                assert.equal(await ElectionModule.isElectionEvaluated(), false);
+              });
+
+              it('shows that some candidate votes where processed', async function () {
+                assertBn.equal(await ElectionModule.getCandidateVotes(candidate1.address), 4);
+                assertBn.equal(await ElectionModule.getCandidateVotes(candidate2.address), 4);
+                assertBn.equal(await ElectionModule.getCandidateVotes(candidate3.address), 0);
+                assertBn.equal(await ElectionModule.getCandidateVotes(candidate4.address), 0);
+                assertBn.equal(await ElectionModule.getCandidateVotes(candidate5.address), 0);
+              });
+
+              describe('totally', function () {
+                before('evaluate', async function () {
+                  const tx = await ElectionModule.evaluate(0);
+                  receipt = await tx.wait();
+                });
+
+                it('emitted an ElectionEvaluated event', async function () {
+                  const event = findEvent({ receipt, eventName: 'ElectionEvaluated' });
+
+                  assert.ok(event);
+                  assertBn.equal(event.args.epochIndex, 1);
+                  assertBn.equal(event.args.totalBallots, 3);
+                });
+
+                it('shows that the epoch is evaluated', async function () {
+                  assert.ok(await ElectionModule.isElectionEvaluated());
+                });
+
+                it('shows that candidate votes where processed', async function () {
+                  assertBn.equal(await ElectionModule.getCandidateVotes(candidate1.address), 4);
+                  assertBn.equal(await ElectionModule.getCandidateVotes(candidate2.address), 4);
+                  assertBn.equal(await ElectionModule.getCandidateVotes(candidate3.address), 1);
+                  assertBn.equal(await ElectionModule.getCandidateVotes(candidate4.address), 0);
+                  assertBn.equal(await ElectionModule.getCandidateVotes(candidate5.address), 5);
+                });
+
+                it('shows the election winners', async function () {
+                  const winners = await ElectionModule.getElectionWinners();
+
+                  assert.equal(winners.length, 3);
+
+                  assert.equal(winners.includes(candidate1.address), true);
+                  assert.equal(winners.includes(candidate2.address), true);
+                  assert.equal(winners.includes(candidate5.address), true);
+                });
+
+                describe('when attempting to evaluate the epoch again', () => {
+                  it('reverts', async () => {
+                    await assertRevert(ElectionModule.evaluate(0), 'AlreadyEvaluated');
+                  });
+                });
+
+                describe('when resolving the epoch', function () {
+                  before('record the epoch index', async function () {
+                    epochIndexBefore = await ElectionModule.getEpochIndex();
+                  });
+
+                  before('resolve', async function () {
+                    await ElectionModule.resolve();
+                  });
+
+                  describe('when a new epoch starts', function () {
+                    it('shows that the epoch index increased', async function () {
+                      const epochIndexAfter = await ElectionModule.getEpochIndex();
+
+                      assertBn.equal(epochIndexAfter, epochIndexBefore.add(1));
+                    });
+
+                    it('shows that the current period is Idle', async function () {
+                      assertBn.equal(await ElectionModule.getCurrentPeriod(), ElectionPeriod.Idle);
+                    });
+                  });
+                });
+              });
+            });
+          });
+        });
+      });
+    });
+  });
+});
diff --git a/packages/core-modules/test/contracts/modules/ElectionModule/Initialize.test.js b/packages/core-modules/test/contracts/modules/ElectionModule/Initialize.test.js
new file mode 100644
index 00000000..cf109e0b
--- /dev/null
+++ b/packages/core-modules/test/contracts/modules/ElectionModule/Initialize.test.js
@@ -0,0 +1,210 @@
+const { ethers } = hre;
+const assert = require('assert/strict');
+const assertBn = require('@synthetixio/core-js/utils/assertions/assert-bignumber');
+const assertRevert = require('@synthetixio/core-js/utils/assertions/assert-revert');
+const { daysToSeconds } = require('@synthetixio/core-js/utils/misc/dates');
+const { getTime } = require('@synthetixio/core-js/utils/hardhat/rpc');
+const { bootstrap } = require('@synthetixio/deployer/utils/tests');
+const initializer = require('../../../helpers/initializer');
+const { ElectionPeriod, assertDatesAreClose } = require('./helpers/election-helper');
+const { findEvent } = require('@synthetixio/core-js/utils/ethers/events');
+
+describe('ElectionModule (initialization)', () => {
+  const { proxyAddress } = bootstrap(initializer);
+
+  let ElectionModule;
+
+  let owner, user;
+
+  let epochStartDate, epochEndDate, nominationPeriodStartDate, votingPeriodStartDate;
+
+  let receipt;
+
+  before('identify signers', async () => {
+    [owner, user] = await ethers.getSigners();
+  });
+
+  before('identify modules', async () => {
+    ElectionModule = await ethers.getContractAt('ElectionModule', proxyAddress());
+  });
+
+  describe('before initializing the module', function () {
+    it('shows that the module is not initialized', async () => {
+      assert.equal(await ElectionModule.isElectionModuleInitialized(), false);
+    });
+
+    it('shows that the council token does not exist', async function () {
+      assert.equal(
+        await ElectionModule.getCouncilToken(),
+        '0x0000000000000000000000000000000000000000'
+      );
+    });
+
+    describe('trying to retrieve the period', function () {
+      it('reverts', async function () {
+        await assertRevert(ElectionModule.getCurrentPeriod(), 'InvalidEpochConfiguration');
+      });
+    });
+  });
+
+  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('', '', 0, 0, 0),
+          'Unauthorized'
+        );
+      });
+    });
+
+    describe('with the account that owns the instance', function () {
+      describe('with invalid parameters', function () {
+        describe('with incorrect date order', function () {
+          it('reverts', async function () {
+            const now = await getTime(ethers.provider);
+            const date1 = now + daysToSeconds(2);
+            const date2 = date1 + daysToSeconds(2);
+            const date3 = date2 + daysToSeconds(2);
+
+            await assertRevert(
+              ElectionModule.connect(owner).initializeElectionModule('', '', date2, date1, date3),
+              'InvalidEpochConfiguration'
+            );
+            await assertRevert(
+              ElectionModule.initializeElectionModule('', '', date1, date3, date2),
+              'InvalidEpochConfiguration'
+            );
+            await assertRevert(
+              ElectionModule.initializeElectionModule('', '', date3, date2, date1),
+              'InvalidEpochConfiguration'
+            );
+          });
+        });
+
+        describe('with any short duration', function () {
+          it('reverts', async function () {
+            const now = await getTime(ethers.provider);
+
+            epochEndDate = now + daysToSeconds(4);
+            votingPeriodStartDate = epochEndDate - daysToSeconds(2);
+            nominationPeriodStartDate = votingPeriodStartDate - daysToSeconds(2);
+            await assertRevert(
+              ElectionModule.connect(owner).initializeElectionModule(
+                '',
+                '',
+                nominationPeriodStartDate,
+                votingPeriodStartDate,
+                epochEndDate
+              ),
+              'InvalidEpochConfiguration'
+            );
+
+            epochEndDate = now + daysToSeconds(7);
+            votingPeriodStartDate = epochEndDate - daysToSeconds(1);
+            nominationPeriodStartDate = votingPeriodStartDate - daysToSeconds(2);
+            await assertRevert(
+              ElectionModule.connect(owner).initializeElectionModule(
+                '',
+                '',
+                nominationPeriodStartDate,
+                votingPeriodStartDate,
+                epochEndDate
+              ),
+              'InvalidEpochConfiguration'
+            );
+
+            epochEndDate = now + daysToSeconds(7);
+            votingPeriodStartDate = epochEndDate - daysToSeconds(2);
+            nominationPeriodStartDate = votingPeriodStartDate - daysToSeconds(1);
+            await assertRevert(
+              ElectionModule.connect(owner).initializeElectionModule(
+                '',
+                '',
+                now + daysToSeconds(1),
+                now + daysToSeconds(2),
+                now + daysToSeconds(7)
+              ),
+              'InvalidEpochConfiguration'
+            );
+          });
+        });
+      });
+
+      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(
+            '',
+            '',
+            nominationPeriodStartDate,
+            votingPeriodStartDate,
+            epochEndDate
+          );
+          receipt = await tx.wait();
+        });
+
+        it('emitted an ElectionModuleInitialized event', async function () {
+          const event = findEvent({ receipt, eventName: 'ElectionModuleInitialized' });
+
+          assert.ok(event);
+        });
+
+        it('emitted an EpochStarted event', async function () {
+          const event = findEvent({ receipt, eventName: 'EpochStarted' });
+
+          assert.ok(event);
+          assertBn.equal(event.args.epochIndex, 1);
+        });
+
+        it('shows that the module is initialized', async () => {
+          assert.equal(await ElectionModule.isElectionModuleInitialized(), true);
+        });
+
+        it('shows that the current epoch index is 1', async function () {
+          assertBn.equal(await ElectionModule.getEpochIndex(), 1);
+        });
+
+        it('shows that the current period is Idle', async () => {
+          assertBn.equal(await ElectionModule.getCurrentPeriod(), ElectionPeriod.Idle);
+        });
+
+        it('shows that the owner is the single council member', async function () {
+          assert.deepEqual(await ElectionModule.getCouncilMembers(), [owner.address]);
+        });
+
+        it('shows that the first epoch has appropriate dates', async function () {
+          assertDatesAreClose(await ElectionModule.getEpochStartDate(), epochStartDate);
+          assertDatesAreClose(
+            await ElectionModule.getVotingPeriodStartDate(),
+            votingPeriodStartDate
+          );
+          assertDatesAreClose(
+            await ElectionModule.getNominationPeriodStartDate(),
+            nominationPeriodStartDate
+          );
+          assertDatesAreClose(await ElectionModule.getEpochEndDate(), epochEndDate);
+        });
+
+        it('shows that the council token was created', async function () {
+          assert.notEqual(
+            await ElectionModule.getCouncilToken(),
+            '0x0000000000000000000000000000000000000000'
+          );
+        });
+
+        describe('when attemting to re-initialize the module', function () {
+          it('reverts', async function () {
+            await assertRevert(
+              ElectionModule.initializeElectionModule('', '', 0, 0, 0),
+              'AlreadyInitialized'
+            );
+          });
+        });
+      });
+    });
+  });
+});
diff --git a/packages/core-modules/test/contracts/modules/ElectionModule/Nominate.test.js b/packages/core-modules/test/contracts/modules/ElectionModule/Nominate.test.js
new file mode 100644
index 00000000..0c25acae
--- /dev/null
+++ b/packages/core-modules/test/contracts/modules/ElectionModule/Nominate.test.js
@@ -0,0 +1,176 @@
+const { ethers } = hre;
+const assert = require('assert/strict');
+const assertBn = require('@synthetixio/core-js/utils/assertions/assert-bignumber');
+const assertRevert = require('@synthetixio/core-js/utils/assertions/assert-revert');
+const { getTime, fastForwardTo } = require('@synthetixio/core-js/utils/hardhat/rpc');
+const { daysToSeconds } = require('@synthetixio/core-js/utils/misc/dates');
+const { bootstrap } = require('@synthetixio/deployer/utils/tests');
+const initializer = require('../../../helpers/initializer');
+const { ElectionPeriod } = require('./helpers/election-helper');
+const { findEvent } = require('@synthetixio/core-js/utils/ethers/events');
+
+describe('ElectionModule (nominate)', () => {
+  const { proxyAddress } = bootstrap(initializer);
+
+  let ElectionModule;
+
+  let user, users;
+
+  let nominees = [];
+
+  let receipt;
+
+  async function nominate(signer) {
+    nominees.push(signer.address);
+
+    const tx = await ElectionModule.connect(signer).nominate();
+    receipt = await tx.wait();
+  }
+
+  async function withdrawNomination(signer) {
+    const index = nominees.indexOf(signer.address);
+
+    if (index !== nominees.length - 1) {
+      const lastValue = nominees[nominees.length - 1];
+
+      nominees[index] = lastValue;
+    }
+
+    nominees.pop();
+
+    const tx = await ElectionModule.connect(signer).withdrawNomination();
+    receipt = await tx.wait();
+  }
+
+  const itProperlyRecordsNominees = () => {
+    it('records nominated and non-nominated users', async function () {
+      for (let user of users) {
+        assert.equal(
+          await ElectionModule.isNominated(user.address),
+          nominees.includes(user.address)
+        );
+      }
+    });
+
+    it('tracks the nominee array', async function () {
+      assert.deepEqual(await ElectionModule.getNominees(), nominees);
+    });
+  };
+
+  before('identify signers', async () => {
+    users = await ethers.getSigners();
+
+    [user] = users;
+  });
+
+  before('identify modules', async () => {
+    ElectionModule = await ethers.getContractAt('ElectionModule', proxyAddress());
+  });
+
+  describe('when the 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(
+        'Spartan Council Token',
+        'SCT',
+        nominationPeriodStartDate,
+        votingPeriodStartDate,
+        epochEndDate
+      );
+    });
+
+    describe('when entering the nomination period', function () {
+      before('fast forward', async function () {
+        await fastForwardTo(await ElectionModule.getNominationPeriodStartDate(), ethers.provider);
+      });
+
+      it('shows that the current period is Nomination', async function () {
+        assertBn.equal(await ElectionModule.getCurrentPeriod(), ElectionPeriod.Nomination);
+      });
+
+      describe('before users nominate', function () {
+        itProperlyRecordsNominees();
+      });
+
+      describe('when a user self nominates', function () {
+        before('nominate', async function () {
+          await nominate(user);
+        });
+
+        it('emitted an CandidateNominated event', async function () {
+          const event = findEvent({ receipt, eventName: 'CandidateNominated' });
+
+          assert.ok(event);
+          assertBn.equal(event.args.candidate, user.address);
+        });
+
+        itProperlyRecordsNominees();
+
+        describe('when a user that is not nominated attempts to withdraw', function () {
+          it('reverts', async function () {
+            await assertRevert(
+              ElectionModule.connect(users[2]).withdrawNomination(),
+              'NotNominated'
+            );
+          });
+        });
+
+        describe('when more users nominate', function () {
+          before('nominate', async function () {
+            await nominate(users[2]);
+            await nominate(users[3]);
+            await nominate(users[4]);
+          });
+
+          itProperlyRecordsNominees();
+
+          describe('when a user attempts to nominate again', function () {
+            it('reverts', async function () {
+              await assertRevert(ElectionModule.connect(user).nominate(), 'AlreadyNominated');
+            });
+          });
+
+          describe('when a user withdraws its nomination', function () {
+            before('withdraw', async function () {
+              await withdrawNomination(user);
+            });
+
+            it('emitted an NominationWithdrawn event', async function () {
+              const event = findEvent({ receipt, eventName: 'NominationWithdrawn' });
+
+              assert.ok(event);
+              assertBn.equal(event.args.candidate, user.address);
+            });
+
+            itProperlyRecordsNominees();
+
+            describe('when more users nominate', function () {
+              before('nominate', async function () {
+                await nominate(users[5]);
+                await nominate(users[7]);
+                await nominate(users[10]);
+                await nominate(users[12]);
+                await nominate(users[16]);
+              });
+
+              itProperlyRecordsNominees();
+
+              describe('when more users withdraw nominations', function () {
+                before('withdraw', async function () {
+                  await withdrawNomination(users[7]);
+                  await withdrawNomination(users[12]);
+                });
+
+                itProperlyRecordsNominees();
+              });
+            });
+          });
+        });
+      });
+    });
+  });
+});
diff --git a/packages/core-modules/test/contracts/modules/ElectionModule/Resolve.test.js b/packages/core-modules/test/contracts/modules/ElectionModule/Resolve.test.js
new file mode 100644
index 00000000..84711b5e
--- /dev/null
+++ b/packages/core-modules/test/contracts/modules/ElectionModule/Resolve.test.js
@@ -0,0 +1,229 @@
+const { ethers } = hre;
+const assert = require('assert/strict');
+const assertBn = require('@synthetixio/core-js/utils/assertions/assert-bignumber');
+const { getTime, fastForwardTo } = require('@synthetixio/core-js/utils/hardhat/rpc');
+const { daysToSeconds } = require('@synthetixio/core-js/utils/misc/dates');
+const { bootstrap } = require('@synthetixio/deployer/utils/tests');
+const initializer = require('../../../helpers/initializer');
+const { findEvent } = require('@synthetixio/core-js/utils/ethers/events');
+
+describe('ElectionModule (resolve)', () => {
+  const { proxyAddress } = bootstrap(initializer);
+
+  let ElectionModule, CouncilToken;
+
+  let owner;
+  let member1, member2, member3, member4, member5;
+
+  let members;
+
+  let receipt;
+
+  async function simulateAndResolveElection() {
+    // Nominate
+    await fastForwardTo(await ElectionModule.getNominationPeriodStartDate(), ethers.provider);
+    for (let member of members) {
+      await ElectionModule.connect(member).nominate();
+    }
+
+    // Vote
+    await fastForwardTo(await ElectionModule.getVotingPeriodStartDate(), ethers.provider);
+    for (let member of members) {
+      await ElectionModule.connect(member).elect([member.address]);
+    }
+
+    // Evaluate
+    await fastForwardTo(await ElectionModule.getEpochEndDate(), ethers.provider);
+    await ElectionModule.evaluate(0);
+    assert.equal(await ElectionModule.isElectionEvaluated(), true);
+    assert.deepEqual(
+      await ElectionModule.getElectionWinners(),
+      members.map((w) => w.address)
+    );
+
+    // Resolve
+    const tx = await ElectionModule.resolve();
+    receipt = await tx.wait();
+  }
+
+  async function itProperlyManagesCredentials() {
+    it('shows that the members are in the council', async function () {
+      assert.deepEqual(
+        await ElectionModule.getCouncilMembers(),
+        members.map((m) => m.address)
+      );
+    });
+
+    it('shows that all members hold their corresponding NFT', async function () {
+      for (let member of members) {
+        assertBn.equal(await CouncilToken.balanceOf(member.address), 1);
+
+        const tokenId = members.indexOf(member) + 1;
+        assert.equal(await CouncilToken.ownerOf(tokenId), member.address);
+      }
+    });
+  }
+
+  before('identify signers', async () => {
+    const users = await ethers.getSigners();
+
+    [owner, member1, member2, member3, member4, member5] = users;
+  });
+
+  before('identify modules', async () => {
+    ElectionModule = await ethers.getContractAt('ElectionModule', proxyAddress());
+  });
+
+  describe('when the 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(
+        'Spartan Council Token',
+        'SCT',
+        nominationPeriodStartDate,
+        votingPeriodStartDate,
+        epochEndDate
+      );
+    });
+
+    before('identify the council token', async function () {
+      const tokenAddress = await ElectionModule.getCouncilToken();
+
+      CouncilToken = await ethers.getContractAt('CouncilToken', tokenAddress);
+    });
+
+    describe('epoch 1', function () {
+      before('define members', async function () {
+        members = [owner];
+      });
+
+      it('shows that the current epoch is 1', async function () {
+        assertBn.equal(await ElectionModule.getEpochIndex(), 1);
+      });
+
+      itProperlyManagesCredentials();
+
+      describe('epoch 2', function () {
+        describe('when members 1, 2, and 3 are elected', function () {
+          before('define members', async function () {
+            members = [member1, member2, member3];
+          });
+
+          before('simulate election', async function () {
+            await simulateAndResolveElection();
+          });
+
+          it('shows that the current epoch is 2', async function () {
+            assertBn.equal(await ElectionModule.getEpochIndex(), 2);
+          });
+
+          itProperlyManagesCredentials();
+
+          it('emitted an EpochStarted event', async function () {
+            const event = findEvent({ receipt, eventName: 'EpochStarted' });
+
+            assert.ok(event);
+            assertBn.equal(event.args.epochIndex, 2);
+          });
+
+          it('emitted CouncilMemberRemoved events', async function () {
+            const event = findEvent({ receipt, eventName: 'CouncilMemberRemoved' });
+            assert.ok(event);
+
+            assert.equal(event.args.member, owner.address);
+          });
+
+          it('emitted CouncilMemberAdded events', async function () {
+            const events = findEvent({ receipt, eventName: 'CouncilMemberAdded' });
+            assert.ok(events);
+
+            const addedMembers = events.map((e) => e.args.member);
+            assert.equal(addedMembers.length, 3);
+
+            assert.ok(addedMembers.includes(member1.address));
+            assert.ok(addedMembers.includes(member2.address));
+            assert.ok(addedMembers.includes(member3.address));
+          });
+        });
+
+        describe('epoch 3', function () {
+          describe('when members 2, 3, and 5 are elected', function () {
+            before('define members', async function () {
+              members = [member2, member3, member5];
+            });
+
+            before('simulate election', async function () {
+              await simulateAndResolveElection();
+            });
+
+            it('shows that the current epoch is 3', async function () {
+              assertBn.equal(await ElectionModule.getEpochIndex(), 3);
+            });
+
+            itProperlyManagesCredentials();
+
+            it('emitted an EpochStarted event', async function () {
+              const event = findEvent({ receipt, eventName: 'EpochStarted' });
+
+              assert.ok(event);
+              assertBn.equal(event.args.epochIndex, 3);
+            });
+
+            it('emitted CouncilMemberRemoved events', async function () {
+              const events = findEvent({ receipt, eventName: 'CouncilMemberRemoved' });
+              assert.ok(events);
+
+              const removedMembers = events.map((e) => e.args.member);
+              assert.equal(removedMembers.length, 3);
+
+              assert.ok(removedMembers.includes(member1.address));
+              assert.ok(removedMembers.includes(member2.address));
+              assert.ok(removedMembers.includes(member3.address));
+            });
+
+            it('emitted CouncilMemberAdded events', async function () {
+              const events = findEvent({ receipt, eventName: 'CouncilMemberAdded' });
+              assert.ok(events);
+
+              const addedMembers = events.map((e) => e.args.member);
+              assert.equal(addedMembers.length, 3);
+
+              assert.ok(addedMembers.includes(member2.address));
+              assert.ok(addedMembers.includes(member3.address));
+              assert.ok(addedMembers.includes(member5.address));
+            });
+
+            describe('epoch 4', function () {
+              describe('when members 3, 4, and 2 are elected', function () {
+                before('define members', async function () {
+                  members = [member3, member4, member2];
+                });
+
+                before('simulate election', async function () {
+                  await simulateAndResolveElection();
+                });
+
+                it('shows that the current epoch is 4', async function () {
+                  assertBn.equal(await ElectionModule.getEpochIndex(), 4);
+                });
+
+                itProperlyManagesCredentials();
+
+                it('emitted an EpochStarted event', async function () {
+                  const event = findEvent({ receipt, eventName: 'EpochStarted' });
+
+                  assert.ok(event);
+                  assertBn.equal(event.args.epochIndex, 4);
+                });
+              });
+            });
+          });
+        });
+      });
+    });
+  });
+});
diff --git a/packages/core-modules/test/contracts/modules/ElectionModule/Schedule.test.js b/packages/core-modules/test/contracts/modules/ElectionModule/Schedule.test.js
new file mode 100644
index 00000000..638c3bed
--- /dev/null
+++ b/packages/core-modules/test/contracts/modules/ElectionModule/Schedule.test.js
@@ -0,0 +1,462 @@
+const { ethers } = hre;
+const assert = require('assert/strict');
+const assertBn = require('@synthetixio/core-js/utils/assertions/assert-bignumber');
+const assertRevert = require('@synthetixio/core-js/utils/assertions/assert-revert');
+const {
+  getTime,
+  fastForwardTo,
+  takeSnapshot,
+  restoreSnapshot,
+} = require('@synthetixio/core-js/utils/hardhat/rpc');
+const { daysToSeconds } = require('@synthetixio/core-js/utils/misc/dates');
+const { bootstrap } = require('@synthetixio/deployer/utils/tests');
+const initializer = require('../../../helpers/initializer');
+const { ElectionPeriod, assertDatesAreClose } = require('./helpers/election-helper');
+const { findEvent } = require('@synthetixio/core-js/utils/ethers/events');
+
+describe('ElectionModule (schedule)', () => {
+  const { proxyAddress } = bootstrap(initializer);
+
+  let ElectionModule;
+
+  let owner, user;
+
+  let snapshotId;
+
+  let receipt;
+
+  let newNominationPeriodStartDate, newVotingPeriodStartDate, newEpochEndDate;
+
+  before('identify signers', async () => {
+    [owner, user] = await ethers.getSigners();
+  });
+
+  before('identify modules', async () => {
+    ElectionModule = (await ethers.getContractAt('ElectionModule', proxyAddress())).connect(owner);
+  });
+
+  // ----------------------------------
+  // Nomination behavior
+  // ----------------------------------
+
+  const itRejectsNominations = () => {
+    describe('when trying to call the nominate function', function () {
+      it('reverts', async function () {
+        await assertRevert(ElectionModule.nominate(), 'NotCallableInCurrentPeriod');
+      });
+    });
+
+    describe('when trying to call the withdrawNomination function', function () {
+      it('reverts', async function () {
+        await assertRevert(ElectionModule.nominate(), 'NotCallableInCurrentPeriod');
+      });
+    });
+  };
+
+  const itAcceptsNominations = () => {
+    describe('when trying to call the nominate function', function () {
+      it('does not revert', async function () {
+        await ElectionModule.nominate();
+      });
+    });
+
+    describe('when a user withdraws its nomination', function () {
+      it('does not revert', async function () {
+        await ElectionModule.withdrawNomination();
+      });
+    });
+  };
+
+  // ----------------------------------
+  // Vote behavior
+  // ----------------------------------
+
+  const itRejectsVotes = () => {
+    describe('when trying to call the elect function', function () {
+      it('reverts', async function () {
+        await assertRevert(ElectionModule.elect([user.address]), 'NotCallableInCurrentPeriod');
+      });
+    });
+  };
+
+  const itAcceptsVotes = () => {
+    describe('when trying to call the elect function', function () {
+      it('does not revert', async function () {
+        await ElectionModule.elect([user.address]);
+      });
+    });
+  };
+
+  // ----------------------------------
+  // Evaluation behaviors
+  // ----------------------------------
+
+  const itRejectsEvaluations = () => {
+    describe('when trying to call the evaluate function', function () {
+      it('reverts', async function () {
+        await assertRevert(ElectionModule.evaluate(0), 'NotCallableInCurrentPeriod');
+      });
+    });
+
+    describe('when trying to call the resolve function', function () {
+      it('reverts', async function () {
+        await assertRevert(ElectionModule.evaluate(0), 'NotCallableInCurrentPeriod');
+      });
+    });
+  };
+
+  const itAcceptsEvaluations = () => {
+    describe('when trying to call the evaluate function', function () {
+      it('does not revert', async function () {
+        await ElectionModule.evaluate(0);
+      });
+    });
+
+    describe('when trying to call the resolve function', function () {
+      it('does not revert', async function () {
+        await ElectionModule.resolve();
+      });
+    });
+  };
+
+  // ----------------------------------
+  // Evaluation behaviors
+  // ----------------------------------
+
+  const itRejectsAdjustments = () => {
+    describe('when trying to call the tweakEpochSchedule function', function () {
+      it('reverts', async function () {
+        await assertRevert(
+          ElectionModule.tweakEpochSchedule(0, 0, 0),
+          'NotCallableInCurrentPeriod'
+        );
+      });
+    });
+
+    describe('when trying to call the modifyEpochSchedule function', function () {
+      it('reverts', async function () {
+        await assertRevert(
+          ElectionModule.modifyEpochSchedule(0, 0, 0),
+          'NotCallableInCurrentPeriod'
+        );
+      });
+    });
+  };
+
+  const itAcceptsAdjustments = () => {
+    describe('when trying to adjust the epoch schedule', function () {
+      before('fast forward', async function () {
+        await fastForwardTo(
+          (await ElectionModule.getNominationPeriodStartDate()) - daysToSeconds(1),
+          ethers.provider
+        );
+      });
+
+      before('fast forward', async function () {
+        await fastForwardTo(
+          (await ElectionModule.getNominationPeriodStartDate()) - daysToSeconds(1),
+          ethers.provider
+        );
+      });
+
+      before('take snapshot', async function () {
+        snapshotId = await takeSnapshot(ethers.provider);
+      });
+
+      after('restore snapshot', async function () {
+        await restoreSnapshot(snapshotId, ethers.provider);
+      });
+
+      describe('with zero dates', function () {
+        it('reverts', async function () {
+          await assertRevert(
+            ElectionModule.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 epochEndDate = (await ElectionModule.getEpochEndDate()).toNumber();
+            const nominationPeriodStartDate = (
+              await ElectionModule.getNominationPeriodStartDate()
+            ).toNumber();
+            const votingPeriodStartDate = (
+              await ElectionModule.getVotingPeriodStartDate()
+            ).toNumber();
+
+            await assertRevert(
+              ElectionModule.tweakEpochSchedule(
+                nominationPeriodStartDate + daysToSeconds(2),
+                votingPeriodStartDate + daysToSeconds(2),
+                epochEndDate + daysToSeconds(8)
+              ),
+              'InvalidEpochConfiguration'
+            );
+            await assertRevert(
+              ElectionModule.tweakEpochSchedule(
+                nominationPeriodStartDate - daysToSeconds(8),
+                votingPeriodStartDate + daysToSeconds(2),
+                epochEndDate + daysToSeconds(7)
+              ),
+              'InvalidEpochConfiguration'
+            );
+            await assertRevert(
+              ElectionModule.tweakEpochSchedule(
+                nominationPeriodStartDate + daysToSeconds(2),
+                votingPeriodStartDate - daysToSeconds(8),
+                epochEndDate + daysToSeconds(7)
+              ),
+              'InvalidEpochConfiguration'
+            );
+          });
+        });
+
+        describe('with dates close to the current dates', function () {
+          describe('which change the current period type', function () {
+            it('reverts', async function () {
+              await assertRevert(
+                ElectionModule.tweakEpochSchedule(
+                  (await ElectionModule.getNominationPeriodStartDate()).toNumber() -
+                    daysToSeconds(2),
+                  (await ElectionModule.getVotingPeriodStartDate()).toNumber() + daysToSeconds(0.5),
+                  (await ElectionModule.getEpochEndDate()).toNumber() + daysToSeconds(4)
+                ),
+                'ChangesCurrentPeriod'
+              );
+            });
+          });
+
+          describe('which dont change the current period type', function () {
+            before('adjust', async function () {
+              newEpochEndDate =
+                (await ElectionModule.getEpochEndDate()).toNumber() + daysToSeconds(4);
+              newNominationPeriodStartDate =
+                (await ElectionModule.getNominationPeriodStartDate()).toNumber() -
+                daysToSeconds(0.5);
+              newVotingPeriodStartDate =
+                (await ElectionModule.getVotingPeriodStartDate()).toNumber() + daysToSeconds(0.5);
+
+              const tx = await ElectionModule.tweakEpochSchedule(
+                newNominationPeriodStartDate,
+                newVotingPeriodStartDate,
+                newEpochEndDate
+              );
+              receipt = await tx.wait();
+            });
+
+            it('emitted an EpochScheduleUpdated event', async function () {
+              const event = findEvent({ receipt, eventName: 'EpochScheduleUpdated' });
+
+              assert.ok(event);
+              assertBn.equal(event.args.nominationPeriodStartDate, newNominationPeriodStartDate);
+              assertBn.equal(event.args.votingPeriodStartDate, newVotingPeriodStartDate);
+              assertBn.equal(event.args.epochEndDate, newEpochEndDate);
+            });
+
+            it('properly adjusted dates', async function () {
+              assertDatesAreClose(
+                await ElectionModule.getNominationPeriodStartDate(),
+                newNominationPeriodStartDate
+              );
+              assertDatesAreClose(
+                await ElectionModule.getVotingPeriodStartDate(),
+                newVotingPeriodStartDate
+              );
+              assertDatesAreClose(await ElectionModule.getEpochEndDate(), newEpochEndDate);
+            });
+          });
+        });
+      });
+
+      describe('with major changes', function () {
+        describe('with dates far from the current dates', function () {
+          describe('which change the current period type', function () {
+            it('reverts', async function () {
+              await assertRevert(
+                ElectionModule.modifyEpochSchedule(
+                  (await ElectionModule.getNominationPeriodStartDate()).toNumber() -
+                    daysToSeconds(2),
+                  (await ElectionModule.getVotingPeriodStartDate()).toNumber() + daysToSeconds(100),
+                  (await ElectionModule.getEpochEndDate()).toNumber() + daysToSeconds(100)
+                ),
+                'ChangesCurrentPeriod'
+              );
+            });
+          });
+
+          describe('which dont change the current period type', function () {
+            before('adjust', async function () {
+              newEpochEndDate =
+                (await ElectionModule.getEpochEndDate()).toNumber() + daysToSeconds(100);
+              newNominationPeriodStartDate =
+                (await ElectionModule.getNominationPeriodStartDate()).toNumber() +
+                daysToSeconds(100);
+              newVotingPeriodStartDate =
+                (await ElectionModule.getVotingPeriodStartDate()).toNumber() + daysToSeconds(100);
+
+              const tx = await ElectionModule.modifyEpochSchedule(
+                newNominationPeriodStartDate,
+                newVotingPeriodStartDate,
+                newEpochEndDate
+              );
+              receipt = await tx.wait();
+            });
+
+            it('emitted an EpochScheduleUpdated event', async function () {
+              const event = findEvent({ receipt, eventName: 'EpochScheduleUpdated' });
+
+              assert.ok(event);
+              assertBn.equal(event.args.nominationPeriodStartDate, newNominationPeriodStartDate);
+              assertBn.equal(event.args.votingPeriodStartDate, newVotingPeriodStartDate);
+              assertBn.equal(event.args.epochEndDate, newEpochEndDate);
+            });
+
+            it('properly adjusted dates', async function () {
+              assertDatesAreClose(
+                await ElectionModule.getNominationPeriodStartDate(),
+                newNominationPeriodStartDate
+              );
+              assertDatesAreClose(
+                await ElectionModule.getVotingPeriodStartDate(),
+                newVotingPeriodStartDate
+              );
+              assertDatesAreClose(await ElectionModule.getEpochEndDate(), newEpochEndDate);
+            });
+          });
+        });
+      });
+    });
+  };
+
+  // ----------------------------------
+  // Idle period
+  // ----------------------------------
+
+  describe('when the 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(
+        'Spartan Council Token',
+        'SCT',
+        nominationPeriodStartDate,
+        votingPeriodStartDate,
+        epochEndDate
+      );
+    });
+
+    it('shows that initial period is Idle', async function () {
+      assertBn.equal(await ElectionModule.getCurrentPeriod(), ElectionPeriod.Idle);
+    });
+
+    describe('when an account that does not own the instance attempts to adjust the epoch', function () {
+      it('reverts', async function () {
+        await assertRevert(
+          ElectionModule.connect(user).tweakEpochSchedule(0, 0, 0),
+          'Unauthorized'
+        );
+      });
+    });
+
+    describe('when an account that does not own the instance attempts to unsafely adjust the epoch', function () {
+      it('reverts', async function () {
+        await assertRevert(
+          ElectionModule.connect(user).modifyEpochSchedule(0, 0, 0),
+          'Unauthorized'
+        );
+      });
+    });
+
+    describe('while in the Idle period', function () {
+      itRejectsNominations();
+      itRejectsVotes();
+      itRejectsEvaluations();
+      itAcceptsAdjustments();
+    });
+
+    // ----------------------------------
+    // Nomination period
+    // ----------------------------------
+
+    describe('when entering the nomination period', function () {
+      before('fast forward', async function () {
+        await fastForwardTo(await ElectionModule.getNominationPeriodStartDate(), ethers.provider);
+      });
+
+      it('skipped to the target time', async function () {
+        assertDatesAreClose(
+          await getTime(ethers.provider),
+          await ElectionModule.getNominationPeriodStartDate()
+        );
+      });
+
+      it('shows that the current period is Nomination', async function () {
+        assertBn.equal(await ElectionModule.getCurrentPeriod(), ElectionPeriod.Nomination);
+      });
+
+      itAcceptsNominations();
+      itRejectsVotes();
+      itRejectsEvaluations();
+      itRejectsAdjustments();
+    });
+
+    // ----------------------------------
+    // Vote period
+    // ----------------------------------
+
+    describe('when entering the voting period', function () {
+      before('ensure nominations', async function () {
+        await ElectionModule.connect(user).nominate();
+      });
+
+      before('fast forward', async function () {
+        await fastForwardTo(await ElectionModule.getVotingPeriodStartDate(), ethers.provider);
+      });
+
+      it('skipped to the target time', async function () {
+        assertDatesAreClose(
+          await getTime(ethers.provider),
+          await ElectionModule.getVotingPeriodStartDate()
+        );
+      });
+
+      it('shows that the current period is Vote', async function () {
+        assertBn.equal(await ElectionModule.getCurrentPeriod(), ElectionPeriod.Vote);
+      });
+
+      itRejectsNominations();
+      itAcceptsVotes();
+      itRejectsEvaluations();
+      itRejectsAdjustments();
+    });
+
+    // ----------------------------------
+    // Evaluation period
+    // ----------------------------------
+
+    describe('when entering the evaluation period', function () {
+      before('fast forward', async function () {
+        await fastForwardTo(await ElectionModule.getEpochEndDate(), ethers.provider);
+      });
+
+      it('skipped to the target time', async function () {
+        assertDatesAreClose(await getTime(ethers.provider), await ElectionModule.getEpochEndDate());
+      });
+
+      it('shows that the current period is Evaluation', async function () {
+        assertBn.equal(await ElectionModule.getCurrentPeriod(), ElectionPeriod.Evaluation);
+      });
+
+      itRejectsNominations();
+      itRejectsVotes();
+      itRejectsAdjustments();
+      itAcceptsEvaluations();
+    });
+  });
+});
diff --git a/packages/core-modules/test/contracts/modules/ElectionModule/Settings.test.js b/packages/core-modules/test/contracts/modules/ElectionModule/Settings.test.js
new file mode 100644
index 00000000..f125eb48
--- /dev/null
+++ b/packages/core-modules/test/contracts/modules/ElectionModule/Settings.test.js
@@ -0,0 +1,317 @@
+const { ethers } = hre;
+const assert = require('assert/strict');
+const assertBn = require('@synthetixio/core-js/utils/assertions/assert-bignumber');
+const assertRevert = require('@synthetixio/core-js/utils/assertions/assert-revert');
+const {
+  getTime,
+  takeSnapshot,
+  restoreSnapshot,
+  fastForwardTo,
+} = require('@synthetixio/core-js/utils/hardhat/rpc');
+const { daysToSeconds } = require('@synthetixio/core-js/utils/misc/dates');
+const { bootstrap } = require('@synthetixio/deployer/utils/tests');
+const initializer = require('../../../helpers/initializer');
+const { findEvent } = require('@synthetixio/core-js/utils/ethers/events');
+
+describe('ElectionModule (settings)', () => {
+  const { proxyAddress } = bootstrap(initializer);
+
+  let ElectionModule;
+
+  let user;
+
+  let receipt;
+
+  let snapshotId;
+
+  before('identify signers', async () => {
+    [, user] = await ethers.getSigners();
+  });
+
+  before('identify modules', async () => {
+    ElectionModule = await ethers.getContractAt('ElectionModule', proxyAddress());
+  });
+
+  describe('when the 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(
+        'Spartan Council Token',
+        'SCT',
+        nominationPeriodStartDate,
+        votingPeriodStartDate,
+        epochEndDate
+      );
+    });
+
+    // ---------------------------------------
+    // nextEpochSeatCount
+    // ---------------------------------------
+
+    describe('when configuring the next epoch seat count', function () {
+      describe('with an account that is not the owner', () => {
+        it('reverts', async () => {
+          await assertRevert(ElectionModule.connect(user).setNextEpochSeatCount(1), 'Unauthorized');
+        });
+      });
+
+      describe('with the owner', () => {
+        describe('with zero', () => {
+          it('reverts', async () => {
+            await assertRevert(ElectionModule.setNextEpochSeatCount(0), 'InvalidElectionSettings');
+          });
+        });
+
+        describe('with a value that is too big', () => {
+          it('reverts', async () => {
+            await assertRevert(ElectionModule.setNextEpochSeatCount(1000), 'value out-of-bounds');
+          });
+        });
+
+        describe('with valid parameters', () => {
+          let newNextEpochSeatCount = 8;
+
+          describe('in the nominations period', function () {
+            before('take snapshot and fast forward', async function () {
+              snapshotId = await takeSnapshot(ethers.provider);
+
+              await fastForwardTo(
+                await ElectionModule.getNominationPeriodStartDate(),
+                ethers.provider
+              );
+            });
+
+            after('restore snapshot', async function () {
+              await restoreSnapshot(snapshotId, ethers.provider);
+            });
+
+            it('reverts', async function () {
+              await assertRevert(
+                ElectionModule.setNextEpochSeatCount(42),
+                'NotCallableInCurrentPeriod'
+              );
+            });
+          });
+
+          describe('in the voting period', function () {
+            before('take snapshot and fast forward', async function () {
+              snapshotId = await takeSnapshot(ethers.provider);
+
+              await fastForwardTo(await ElectionModule.getVotingPeriodStartDate(), ethers.provider);
+            });
+
+            after('restore snapshot', async function () {
+              await restoreSnapshot(snapshotId, ethers.provider);
+            });
+
+            it('reverts', async function () {
+              await assertRevert(
+                ElectionModule.setNextEpochSeatCount(42),
+                'NotCallableInCurrentPeriod'
+              );
+            });
+          });
+
+          describe('in the idle period', function () {
+            before('set', async () => {
+              const tx = await ElectionModule.setNextEpochSeatCount(newNextEpochSeatCount);
+              receipt = await tx.wait();
+            });
+
+            it('emitted an NextEpochSeatCountChanged event', async function () {
+              const event = findEvent({ receipt, eventName: 'NextEpochSeatCountChanged' });
+
+              assert.ok(event);
+              assertBn.equal(event.args.seatCount, newNextEpochSeatCount);
+            });
+
+            it('changes the setting', async () => {
+              assertBn.equal(await ElectionModule.getNextEpochSeatCount(), newNextEpochSeatCount);
+            });
+          });
+        });
+      });
+    });
+
+    // ---------------------------------------
+    // defaultBallotEvaluationBatchSize
+    // ---------------------------------------
+
+    describe('when configuring the default ballot evaluation batch size', function () {
+      describe('with an account that is not the owner', () => {
+        it('reverts', async () => {
+          await assertRevert(
+            ElectionModule.connect(user).setDefaultBallotEvaluationBatchSize(1),
+            'Unauthorized'
+          );
+        });
+      });
+
+      describe('with the owner', () => {
+        describe('with zero', () => {
+          it('reverts', async () => {
+            await assertRevert(
+              ElectionModule.setDefaultBallotEvaluationBatchSize(0),
+              'InvalidElectionSettings'
+            );
+          });
+        });
+
+        describe('with valid parameters', () => {
+          let newDefaultBallotEvaluationBatchSize = 100;
+
+          before('set', async () => {
+            const tx = await ElectionModule.setDefaultBallotEvaluationBatchSize(
+              newDefaultBallotEvaluationBatchSize
+            );
+            receipt = await tx.wait();
+          });
+
+          it('emitted an DefaultBallotEvaluationBatchSizeChanged event', async function () {
+            const event = findEvent({
+              receipt,
+              eventName: 'DefaultBallotEvaluationBatchSizeChanged',
+            });
+
+            assert.ok(event);
+            assertBn.equal(event.args.size, newDefaultBallotEvaluationBatchSize);
+          });
+
+          it('changes the setting', async () => {
+            assertBn.equal(
+              await ElectionModule.getDefaultBallotEvaluationBatchSize(),
+              newDefaultBallotEvaluationBatchSize
+            );
+          });
+        });
+      });
+    });
+
+    // ---------------------------------------
+    // maxDateAdjustmentTolerance
+    // ---------------------------------------
+
+    describe('when configuring maximum epoch date adjustment tolerance', function () {
+      describe('with an account that is not the owner', () => {
+        it('reverts', async () => {
+          await assertRevert(
+            ElectionModule.connect(user).setMaxDateAdjustmentTolerance(1),
+            'Unauthorized'
+          );
+        });
+      });
+
+      describe('with the owner', () => {
+        describe('with any zero duration', () => {
+          it('reverts', async () => {
+            await assertRevert(
+              ElectionModule.setMaxDateAdjustmentTolerance(0),
+              'InvalidElectionSettings'
+            );
+          });
+        });
+
+        describe('with valid parameters', () => {
+          let newMaxDateAdjustmentTolerance = daysToSeconds(1);
+
+          before('set', async () => {
+            const tx = await ElectionModule.setMaxDateAdjustmentTolerance(
+              newMaxDateAdjustmentTolerance
+            );
+            receipt = await tx.wait();
+          });
+
+          it('emitted an MaxDateAdjustmentToleranceChanged event', async function () {
+            const event = findEvent({ receipt, eventName: 'MaxDateAdjustmentToleranceChanged' });
+
+            assert.ok(event);
+            assertBn.equal(event.args.tolerance, newMaxDateAdjustmentTolerance);
+          });
+
+          it('changes the setting', async () => {
+            assertBn.equal(
+              await ElectionModule.getMaxDateAdjustmenTolerance(),
+              newMaxDateAdjustmentTolerance
+            );
+          });
+        });
+      });
+    });
+
+    // ---------------------------------------
+    // minEpochDuration
+    // minNominationPeriodDuration
+    // minVotingPeriodDuration
+    // ---------------------------------------
+
+    describe('when configuring minimum epoch and period durations', function () {
+      describe('with an account that is not the owner', () => {
+        it('reverts', async () => {
+          await assertRevert(
+            ElectionModule.connect(user).setMinEpochDurations(1, 1, 1),
+            'Unauthorized'
+          );
+        });
+      });
+
+      describe('with the owner', () => {
+        describe('with any zero duration', () => {
+          it('reverts', async () => {
+            await assertRevert(
+              ElectionModule.setMinEpochDurations(1, 1, 0),
+              'InvalidElectionSettings'
+            );
+            await assertRevert(
+              ElectionModule.setMinEpochDurations(1, 0, 1),
+              'InvalidElectionSettings'
+            );
+            await assertRevert(
+              ElectionModule.setMinEpochDurations(0, 1, 1),
+              'InvalidElectionSettings'
+            );
+          });
+        });
+
+        describe('with valid parameters', () => {
+          let newMinNominationPeriodDuration = daysToSeconds(1);
+          let newMinVotingPeriodDuration = daysToSeconds(3);
+          let newMinEpochDuration = daysToSeconds(365);
+
+          before('set', async () => {
+            const tx = await ElectionModule.setMinEpochDurations(
+              newMinNominationPeriodDuration,
+              newMinVotingPeriodDuration,
+              newMinEpochDuration
+            );
+            receipt = await tx.wait();
+          });
+
+          it('emitted an MinimumEpochDurationsChanged event', async function () {
+            const event = findEvent({ receipt, eventName: 'MinimumEpochDurationsChanged' });
+
+            assert.ok(event);
+            assertBn.equal(event.args.minNominationPeriodDuration, newMinNominationPeriodDuration);
+            assertBn.equal(event.args.minVotingPeriodDuration, newMinVotingPeriodDuration);
+            assertBn.equal(event.args.minEpochDuration, newMinEpochDuration);
+          });
+
+          it('changes the setting', async () => {
+            const [
+              setMinNominationPeriodDuration,
+              setMinVotingPeriodDuration,
+              setMinEpochDuration,
+            ] = await ElectionModule.getMinEpochDurations();
+
+            assertBn.equal(setMinNominationPeriodDuration, newMinNominationPeriodDuration);
+            assertBn.equal(setMinVotingPeriodDuration, newMinVotingPeriodDuration);
+            assertBn.equal(setMinEpochDuration, newMinEpochDuration);
+          });
+        });
+      });
+    });
+  });
+});
diff --git a/packages/core-modules/test/contracts/modules/ElectionModule/Token.test.js b/packages/core-modules/test/contracts/modules/ElectionModule/Token.test.js
new file mode 100644
index 00000000..73bb26d9
--- /dev/null
+++ b/packages/core-modules/test/contracts/modules/ElectionModule/Token.test.js
@@ -0,0 +1,170 @@
+const { ethers } = hre;
+const assert = require('assert/strict');
+const assertBn = require('@synthetixio/core-js/utils/assertions/assert-bignumber');
+const assertRevert = require('@synthetixio/core-js/utils/assertions/assert-revert');
+const { getTime } = require('@synthetixio/core-js/utils/hardhat/rpc');
+const { daysToSeconds } = require('@synthetixio/core-js/utils/misc/dates');
+const { bootstrap } = require('@synthetixio/deployer/utils/tests');
+const initializer = require('../../../helpers/initializer');
+const { findEvent } = require('@synthetixio/core-js/utils/ethers/events');
+
+describe('ElectionModule (token)', () => {
+  const { proxyAddress } = bootstrap(initializer);
+
+  let ElectionModule, CouncilToken;
+
+  let owner, user;
+
+  const TOKEN_NAME = 'Spartan Council Token';
+  const TOKEN_SYMBOL = 'SCT';
+
+  let receipt;
+
+  before('identify signers', async () => {
+    [owner, user] = await ethers.getSigners();
+  });
+
+  before('identify modules', async () => {
+    ElectionModule = await ethers.getContractAt('ElectionModule', proxyAddress());
+  });
+
+  describe('before the module is initialized', function () {
+    describe('when upgrading the council token', function () {
+      it('reverts', async function () {
+        await assertRevert(ElectionModule.upgradeCouncilToken(user.address), 'NotInitialized');
+      });
+    });
+  });
+
+  describe('when the 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);
+
+      const tx = await ElectionModule.initializeElectionModule(
+        TOKEN_NAME,
+        TOKEN_SYMBOL,
+        nominationPeriodStartDate,
+        votingPeriodStartDate,
+        epochEndDate
+      );
+      receipt = await tx.wait();
+    });
+
+    describe('when the council token is identified', function () {
+      before('identify token', async function () {
+        const tokenAddress = await ElectionModule.getCouncilToken();
+
+        CouncilToken = await ethers.getContractAt('CouncilToken', tokenAddress);
+      });
+
+      it('shows that the council token was created', async function () {
+        assert.equal(await ElectionModule.getCouncilToken(), CouncilToken.address);
+      });
+
+      it('emitted an CouncilTokenCreated event', async function () {
+        const event = findEvent({ receipt, eventName: 'CouncilTokenCreated' });
+
+        assert.ok(event);
+        assert.equal(event.args.proxy, CouncilToken.address);
+        assert.equal(event.args.implementation, await CouncilToken.getImplementation());
+      });
+
+      it('has the correct token name and symbol', async function () {
+        assert.equal(await CouncilToken.name(), TOKEN_NAME);
+        assert.equal(await CouncilToken.symbol(), TOKEN_SYMBOL);
+      });
+
+      it('is owned by the system', async function () {
+        assert.equal(await CouncilToken.owner(), proxyAddress());
+      });
+
+      it('shows that the owner holds one token', async function () {
+        assertBn.equal(await CouncilToken.balanceOf(owner.address), 1);
+      });
+
+      it('shows that the first token is held by the owner', async function () {
+        assert.equal(await CouncilToken.ownerOf(1), owner.address);
+      });
+
+      describe('when upgrading the council token', function () {
+        describe('using an account that is not the owner', function () {
+          it('reverts', async function () {
+            await assertRevert(
+              ElectionModule.connect(user).upgradeCouncilToken(user.address),
+              'Unauthorized'
+            );
+          });
+        });
+
+        describe('with the owner account', function () {
+          let NewCouncilTokenImplementation;
+
+          before('upgrade', async function () {
+            const factory = await ethers.getContractFactory('CouncilToken');
+            NewCouncilTokenImplementation = await factory.deploy();
+
+            const tx = await ElectionModule.upgradeCouncilToken(
+              NewCouncilTokenImplementation.address
+            );
+            receipt = await tx.wait();
+          });
+
+          it('emitted an CouncilTokenUpgraded event', async function () {
+            const event = findEvent({ receipt, eventName: 'CouncilTokenUpgraded' });
+
+            assert.ok(event);
+            assert.equal(event.args.newImplementation, NewCouncilTokenImplementation.address);
+          });
+
+          it('shows that the implementation was upgraded', async function () {
+            assert.equal(
+              await CouncilToken.getImplementation(),
+              NewCouncilTokenImplementation.address
+            );
+          });
+        });
+      });
+
+      describe('when attempting to transfer the council token', function () {
+        describe('using transferFrom', function () {
+          it('reverts', async function () {
+            await assertRevert(
+              CouncilToken.transferFrom(owner.address, user.address, 1),
+              'TokenIsNotTransferable'
+            );
+          });
+        });
+
+        describe('using safeTransferFrom without data', function () {
+          it('reverts', async function () {
+            await assertRevert(
+              CouncilToken['safeTransferFrom(address,address,uint256)'](
+                owner.address,
+                user.address,
+                1
+              ),
+              'TokenIsNotTransferable'
+            );
+          });
+        });
+
+        describe('using safeTransferFrom with data', function () {
+          it('reverts', async function () {
+            await assertRevert(
+              CouncilToken['safeTransferFrom(address,address,uint256,bytes)'](
+                owner.address,
+                user.address,
+                1,
+                '0x00'
+              ),
+              'TokenIsNotTransferable'
+            );
+          });
+        });
+      });
+    });
+  });
+});
diff --git a/packages/core-modules/test/contracts/modules/ElectionModule/helpers/election-helper.js b/packages/core-modules/test/contracts/modules/ElectionModule/helpers/election-helper.js
new file mode 100644
index 00000000..021cc539
--- /dev/null
+++ b/packages/core-modules/test/contracts/modules/ElectionModule/helpers/election-helper.js
@@ -0,0 +1,20 @@
+const { ethers } = require('ethers');
+
+const ElectionPeriod = {
+  Idle: 0,
+  Nomination: 1,
+  Vote: 2,
+  Evaluation: 3,
+};
+
+const assertDatesAreClose = (dateA, dateB) => {
+  const numberDateA = ethers.BigNumber.isBigNumber(dateA) ? dateA.toNumber() : dateA;
+  const numberDateB = ethers.BigNumber.isBigNumber(dateB) ? dateB.toNumber() : dateB;
+
+  return Math.abs(numberDateB - numberDateA) <= 1;
+};
+
+module.exports = {
+  ElectionPeriod,
+  assertDatesAreClose,
+};