Skip to content

Instantly share code, notes, and snippets.

@HollaDieWaldfee100
Last active August 31, 2023 11:42
Show Gist options
  • Save HollaDieWaldfee100/cad98a69992a94d7c847700c0e4e31f0 to your computer and use it in GitHub Desktop.
Save HollaDieWaldfee100/cad98a69992a94d7c847700c0e4e31f0 to your computer and use it in GitHub Desktop.

Audit Report - Spire

Audit Date 08/19/2023 - 08/30/2023
Auditor HollaDieWaldfee (@HollaWaldfee100)
Version 1 08/30/2023 Initial Report
Version 2 08/31/2023 Mitigation Review

Contents

Disclaimer

The following smart contract audit report is based on the information and code provided by the client, and any findings or recommendations are made solely on the basis of this information. While the Auditor has exercised due care and skill in conducting the audit, it cannot be guaranteed that all issues have been identified and that there are no undiscovered errors or vulnerabilities in the code.

Furthermore, this report is not an endorsement or certification of the smart contract, and the Auditor does not assume any responsibility for any losses or damages that may result from the use of the smart contracts, either in their current form or in any modified version thereof.

About HollaDieWaldfee

HollaDieWaldfee is a top-ranked Smart Contract Auditor doing audits on code4rena (www.code4rena.com) and Sherlock (www.sherlock.xyz), having ranked 1st in multiple contests.
On Sherlock he uses the handle "roguereddwarf" to compete in contests.
He can also be booked for conducting Private Audits.

Contact:

Twitter: @HollaWaldfee100

Scope

The audit has been conducted in the sponsor's private repository (https://github.com/Hyy-pe/Spire-Contracts) at commit 665b6ab119dd1ded4f388f39d5164823f4735dfb.

The in-scope files are all files in the /src directory.

The mitigation review has been conducted at commit 7ba807b470c7d2e78dfafab2f320e8a35b0591a5.

Centralization

The protocol must be considered centralized. The MID_ADMIN_ROLE, SUPER_ADMIN_ROLE and owner of the Spire contract must be fully trusted.

The MID_ADMIN_ROLE is able to call the Spire.setURI function (Link) for any tokenId.

Since the ERC1155 tokens, which are minted by the Spire, derive their value from the text / image they represent, changing their URI makes them worthless.

In addition to that, the MID_ADMIN_ROLE is required to choose a winner for every contest.
Without the MID_ADMIN_ROLE's action, the Spire cannot advance.

Also, by not selecting a winner, the frozen tokens for approved submissions cannot be unfrozen.
Therefore by making a submission, the user needs to trust the admins of the protocol that he'll get back his frozen token in case his approved submission is not selected as the winner.

Beyond that, the owner of the ConfigStore contract (which in practice is the same as the owner of the Spire) can change implementation addresses which is another way of messing with the protocol.

What's not subject to centralization risk though are the staked tokens in the ContestStaker contract (Link) which are unfrozen. They are fully controlled by the user that has staked them and it's not possible for a privileged role to access them.

Severity Classification

Severity Impact: High Impact: Medium Impact: Low
Likelihood: High high high medium
Likelihood: Medium high medium low
Likelihood: Low medium low low

Impact - the technical, economic and reputation damage of a successful attack

Likelihood - the chance that a particular vulnerability is discovered and exploited

improvement: Findings in this category are recommended changes that are not related to security but can improve structure, usability and overall effectiveness of the protocol.

Summary

Severity Total Fixed Acknowledged Disputed Reported
high 2 2 0 0 0
medium 0 0 0 0 0
low 2 2 0 0 0
improvement 34 34 0 0 0
# Title Severity Status
1 Griefing attack to halt the Spire caused by onERC1155Received callback high fixed
2 Griefing attack to halt the Spire caused by insufficient access control around Contest.reclaimEntry function high fixed
3 Spire: initializeChapterFlow function can be called multiple times low fixed
4 Contest: cancelEntry function introduces risk of griefing attacks low fixed
5 Use fixed pragma improvement fixed
6 Remove unnecessary reentrancy guards improvement fixed
7 Make public functions that are never called internally external improvement fixed
8 Events can use three indexed parameters improvement fixed
9 ConfigStore.getImplementationUint256: Result can be returned directly improvement fixed
10 Enforce override convention when implementing functions from interfaces improvement fixed
11 Refactor AccessControl._setAdminContractAccess and AccessControl._setTeamContractAccess functions improvement fixed
12 ContestStaker: Use RemoveFromSetFailed error instead of RemoveToSetFailed error improvement fixed
13 Remove unused errors improvement fixed
14 Contest: Use of Address library is not necessary improvement fixed
15 Contest.acceptEntries: noWinner modifier is redundant improvement fixed
16 Contest.setWinningEntry: noWinner modifier is redundant improvement fixed
17 Contest.reclaimEntry: Use EntryNotExistsinstead ofNotEntrant` error improvement fixed
18 Optimize gas usage of loops improvement fixed
19 ContestStaker.unstake: Don't delete values in mapping improvement fixed
20 ContestStaker.getStakeAmountsForUser: Use existing array to return result improvement fixed
21 ContestStaker._removeStakedContract and ContestStaker._removeStakeableTokenId functions contain redundant checks improvement fixed
22 Remove code for "additional echo contests" improvement fixed
23 ContestFactory.sol: Set Spire once upon construction improvement fixed
24 Simplify ToggleGovernance.hasEnoughOpenGovernToggles function improvement fixed
25 Echoes._mintContestWinner: _teamPercent and teamWallet don't need to be saved to memory improvement fixed
26 Echoes._mintContestWinner: Emit more meaningful value in MintContestReward event improvement fixed
27 Echoes: _setInitialEchoContestWinners function should be removed improvement fixed
28 Remove unused imports improvement fixed
29 Remove unused events improvement fixed
30 Remove unused modifiers improvement fixed
31 Variables can be constants improvement fixed
32 Function can be declared as view improvement fixed
33 Rename Spire.setEchoContestWinners to Spire.setEchoContestWinner improvement fixed
34 Echoes: _initialEchoContestsHaveWinners function can be simplified improvement fixed
35 Spire.setEchoContestWinners and Spire.setChapterContestWinner: Gas optimization suggestions improvement fixed
36 Spire._advanceEchoFlowIfPossible and Spire._advanceChapterFlowIfPossible: Gas optimization suggestions improvement fixed
37 Spire.approveChapterContestEntries and Spire.approveEchoContestEntries: Gas optimization suggestions improvement fixed
38 Spire: Echo flow and chapter flow are not time-efficient improvement fixed

Findings

High Risk Findings (2)

1. Griefing attack to halt the Spire caused by onERC1155Received callback high fixed

Description:
Once a sufficient amount of entries has been submitted to a Contest and the minimumContestTime has passed, the Contest is closed. (Link)

At this point no further entries can be submitted. (Link)

This introduces the possibility of a griefing attack.

Impact:
If all entrant addresses are contracts that revert when they get the reward minted and the onERC1155Received callback is executed, it is not possible to select a winner.
Thereby the Spire is halted forever.

Determining the severity of this finding is not straightforward because it relies on the sophistication that we ascribe to the MID_LEVEL_ADMIN who is responsible for approving entries.

Deciding whether an entry is malicious is non-trivial. It requires on-chain analysis and in-depth knowledge of the EVM.

Adding to that the severe consequences, i.e. halting the operation of the Spire forever, I determine this issue to be of High severity.

The following test can be used to verify the issue:

/* @audit-info
add to Echoes.t.sol

add import: import "forge-std/console.sol";

import KillSwitch contract
*/
function test_setInitialEchoContestWinners_HollaDieWaldfee_8() public {
    KillSwitch killSwitch = new KillSwitch();
    address payable[] memory entrant = new address payable[] (1);
    entrant[0] = payable(address(killSwitch));

    echoesHarness.createInitialEchoContests(0, 0, 3600, 4);

    
    // Sets winners on contests that have entries and can be closed.
    for (uint256 i = 0; i < GlobalConstants.INITIAL_ECHO_COUNT - 1; i++) {
        Contest echoContest = Contest(echoesHarness.chapterEchoes(0, 0, i));
        assertFalse(echoContest.hasWinner());
        contestUtils.submitAndAcceptEntries(entrant, echoContest, 4, 1);
    }
    utils.mineTime(3600);

    /* @audit-info
    setting winnner for echo contest 0
    */
    echoesHarness.setInitialEchoContestWinners(0, 0, 0, 0, new uint256[](0));

    /* @audit-info
    make entrant revert upon being minted the reward token
    */
    killSwitch.setKillSwitch(true);
    vm.expectRevert();
    echoesHarness.setInitialEchoContestWinners(0, 0, 1, 0, new uint256[](0));
}

To execute the test, the KillSwitch contract is needed:

pragma solidity ^0.8.0;

contract KillSwitch {
    bool public killSwitch = false;

    function setKillSwitch(bool _killSwitch) external {
        killSwitch = _killSwitch;
    }

    function onERC1155Received(
        address,
        address,
        uint256,
        uint256,
        bytes memory
    ) external view returns (bytes4) {
        if (killSwitch) {
            return bytes4(0x00000000);
        }
        return this.onERC1155Received.selector;
    }
}

Recommendation:
The Spire.setEchoContestWinners, Spire.setChapterContestWinner and Spire.setWinningEntryForAdditionalEchoContest (as I argue in another finding, this third function should be removed) functions should be changed to have an additional bool toBeneficiary parameter that is passed along until the Echoes._mintContestWinner function is reached.

If toBeneficiary = true, the reward for the winner should be minted to the beneficiary instead.

Thereby it's possible for the MID_LEVEL_ADMIN to resolve any situation where the ERC1155._mint call reverts.

Changes to the Echoes._mintContestWinner function:

diff --git a/src/Echoes.sol b/src/Echoes.sol
index d14833d..2159ac5 100644
--- a/src/Echoes.sol
+++ b/src/Echoes.sol
@@ -67,12 +67,16 @@ contract Echoes is HasConfigStore, ERC1155Supply {
         emit URI(uri, tokenId);
     }
 
-    function _mintContestWinner(address winner) internal returns (uint256 contestRewardTokenId) {
+    function _mintContestWinner(address winner, bool toBeneficiary) internal returns (uint256 contestRewardTokenId) {
         contestRewardTokenId = nextContestWinnerId++;
         uint256 _teamPercent = _getTeamPercent();
         uint256 _teamCount = GlobalConstants.CONTEST_REWARD_AMOUNT * _teamPercent / (100);
         address _teamWallet = _getTeamWallet();
-        _mint(winner, contestRewardTokenId, GlobalConstants.CONTEST_REWARD_AMOUNT - _teamCount, "");
+        if (toBeneficiary) {
+            _mint(_getBeneficiary(), contestRewardTokenId, GlobalConstants.CONTEST_REWARD_AMOUNT - _teamCount, "");
+        } else {
+            _mint(winner, contestRewardTokenId, GlobalConstants.CONTEST_REWARD_AMOUNT - _teamCount, "");
+        }
         _mint(_teamWallet, contestRewardTokenId, _teamCount, "");
         emit MintContestReward(contestRewardTokenId, winner, GlobalConstants.CONTEST_REWARD_AMOUNT);
     }

All other changes only involve passing around the toBeneficiary parameter.

Resolution:
The recommendation has been implemented and the toBeneficiary parameter is now passed from the Spire.setChapterContestWinner and Spire.setEchoContestWinner functions down to the Echoes._mintContestWinner function.

The beneficiary address is a trusted address which is known to accept ERC1155 tokens.

2. Griefing attack to halt the Spire caused by insufficient access control around Contest.reclaimEntry function high fixed

Description:
The Contest.reclaimEntry function allows to unfreeze the staked token once a winner has been selected. (Link)

The function can be called by anyone for any entry (except the winning entry).

By unfreezing a token multiple times, the operation of another Contest can be impacted, i.e. it may not be able to select a winner.

Impact:
The severity of this finding is High.

When a Contest is not able to select a winner, the Spire is halted forever.

It is not possible for the MID_LEVEL_ADMIN to detect this attack if it is executed by a determined attacker (using multiple addresses, high quality entries).

The following test can be used to verify the issue:

/* @audit-info
add to Contest.t.sol

add import: import "forge-std/console.sol";
*/
function test_reclaimEntryHollaDieWaldfee() public {
    address payable[] memory entrant = utils.createUsers(1);
    uint256[] memory entryIdsContest1 = contestUtils.submitAndAcceptEntries(entrant, contest, 8, 0);


    /* @audit-info
    Setting up another contest.
    This is the contest that will be DOS'd
    */
    vm.prank(contestAdmin);
    Contest contest2 = Contest(contestFactory.deployNewContest(7 days, 8, configStore));
    uint256[] memory entryIdsContest2 = contestUtils.submitAndAcceptEntries(entrant, contest2, 8, 0);


    utils.mineTime(7 days);

    /* @audit-info
    frozen stakes for the user at this point are 16.
    Both contests have 8 entries, each entry freezinh 1 token
    */
    console.log(contestStaker.frozenStakes(address(stakedToken), 0, entrant[0]));

    /* @audit-info
    winner for contest 1 is selected such that the reclaimEntry function can be called
    */
    vm.prank(contest.owner());
    contest.setWinningEntry(entryIdsContest1[0]);


    /* @audit-info
    frozen stakes of the user are set to zero by repeatedly calling reclaimEntry()
    */
    while (contestStaker.frozenStakes(address(stakedToken), 0, entrant[0]) > 0) {
        contest.reclaimEntry(entryIdsContest1[1]);
        console.log(contestStaker.frozenStakes(address(stakedToken), 0, entrant[0]));
    }

    /* @audit-info
    contest 2 is closed
    since all approved entries in the second contest belong to the user, 
    and the user's frozen stakes are 0, no winning entry can be selected

    reverts here: 
    https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/ContestStaker.sol#L157-L159
    */
    for (uint i = 0; i < entryIdsContest2.length; i++) {
        vm.prank(contest2.owner());
        vm.expectRevert(); // reverts due to insufficient stake
        contest2.setWinningEntry(entryIdsContest2[i]);
    }

    /* @audit-info
    just to illustrate a possible solution that the Spire admins might come up with

    there's no way to recover from this situation without the help of the malicious entrant

    he can hold the Spire hostage
    */
    vm.expectRevert();
    contestUtils.submitAndAcceptEntries(entrant, contest2, 1, 0);
}

Recommendation:
As discussed with the client, the recommendation is to add the onlyOwner modifier to the Contest.reclaimEntry function.

This ensures that it's only possible for the Spire contract to reclaim entries.

The entries that need to be reclaimed are known at the time the winner is selected and their IDs need to be provided by the MID_LEVEL_ADMIN when calling the Spire.setChapterContestWinner and Spire.setEchoContestWinners function (there's also the Spire.setWinningEntryForAdditionalEchoContest function but, as I discuss in another finding, this function should be removed).

An additional layer of safety is to add the if (!entries[entryId].isApproved) revert EntryNotApproved(); check to the Contest.reclaimEntry function.
Thereby the MID_LEVEL_ADMIN cannot reclaim an approved entry which ensures it can't be reclaimed / cancelled twice by calling Contest.cancelEntry as well.

Resolution:
The fix has been implemented as recommended. The onlyOwner modifier has been added to the Contest.reclaimEntry function and the additional check that the entry must be approved has been added as well.

For approved entries, they are "cancelled" by calling Contest.reclaimEntry which can only happen once as part of setting a winner.
On the other hand, unapproved entries are cancelled by calling Contest.cancelEntry which deletes the entry once it's been cancelled and thereby only allows for one cancellation.

In summary it's now ensured that cancellation / reclaiming only happens once.

Also, no other changes to the Contest contract conflict with this fix.

Low Risk Findings (2)

3. Spire: initializeChapterFlow function can be called multiple times low fixed

Description:
The chapter flow must be initialized by the MID_LEVEL_ADMIN by calling Spire.initializeChapterFlow (Link).

It is not ensured that this function is only called once because the check does not cover all ways in which the chapter flow could have been initialized.

Thereby the chapter flow can reach an inconsistent state, leaving it unable to close / set a winner for a chapter contest which means the affected genesis text id becomes stale and cannot advance.

Impact:
The following test can be added to the Spire.t.sol file.

It illustrates how the chapter flow can be initialized twice. First at chapter 0 of genesis text 1, then at chapter 0 of genesis text 2.

The severity of the finding is Low because it requires a mistake made by the MID_LEVEL_ADMIN role which is trusted.

function test_initializeChapterFlowTwice() public {
    uint256 winningId = spireUtils.completeGenesisEchoes(0);
    for (uint256 i = 0; i < GlobalConstants.INITIAL_ECHO_COUNT; i++) {
        vm.prank(spire.owner());
        spire.setEchoContestWinners(0, winningId, i, new uint256[](0));
    }

    /* @audit-info
    set winners for echo contests in genesis text id 1
    */
    winningId = spireUtils.completeGenesisEchoes(1);
    for (uint256 i = 0; i < GlobalConstants.INITIAL_ECHO_COUNT; i++) {
        vm.prank(spire.owner());
        spire.setEchoContestWinners(1, winningId, i, new uint256[](0));
    }

    console.log(spire.previousChapterFlowId());
    console.log(spire.currentChapterFlowId());

    /* @audit-info
    chapter flow starts at genesis text id 1
    */
    ToggleGovernance tg = ToggleGovernance(spire.genesisGovernors(0));
    vm.startPrank(spire.owner());
    spire.setApprovalForAll(address(tg), true);
    tg.stake(GlobalConstants.MAX_CHAPTER_COUNT);
    spire.initializeChapterFlow();
    vm.stopPrank();

    console.log(spire.previousChapterFlowId());
    console.log(spire.currentChapterFlowId());

    /* @audit-info
    set winners for echo contests in genesis text id 2
    */
    winningId = spireUtils.completeGenesisEchoes(2);
    for (uint256 i = 0; i < GlobalConstants.INITIAL_ECHO_COUNT; i++) {
        vm.prank(spire.owner());
        spire.setEchoContestWinners(2, winningId, i, new uint256[](0));
    }

    /* @audit-info
    can initialize chapter flow again at genesis text id 2
    */
    tg = ToggleGovernance(spire.genesisGovernors(1));
    vm.startPrank(spire.owner());
    spire.setApprovalForAll(address(tg), true);
    tg.stake(GlobalConstants.MAX_CHAPTER_COUNT);

    spire.initializeChapterFlow();
    
    vm.stopPrank();

    console.log(spire.previousChapterFlowId());
    console.log(spire.currentChapterFlowId());
}

Recommendation:
Check the value of the _initializeChapterFlow variable instead of whether the chapter contest 0 at genesis text 0 exists.

diff --git a/src/Spire.sol b/src/Spire.sol
index cba361f..eecefb2 100644
--- a/src/Spire.sol
+++ b/src/Spire.sol
@@ -370,7 +370,7 @@ contract Spire is ISpire, GenesisText, ERC1155Holder, AccessControl, Ownable, Re
     // check whether the first chapter was created everytime we set an Echo chapter contest winner. We only need to
     // kickstart the chapter flow once, so requiring it to be manually called once seems OK.
     function initializeChapterFlow() external nonReentrant {
-        if (_hasCreatedChapterContest(0, 0)) revert ChapterFlowInitialized();
+        if (_initializeChapterFlow) revert ChapterFlowInitialized();
 
         while (_shouldSkipGenesisText(currentChapterFlowId, Flow.Chapter)) {
             if (currentChapterFlowId + 1 < GlobalConstants.GENESIS_TEXT_COUNT) {

Resolution:
The recommendation has been implemented.

4. Contest: cancelEntry function introduces risk of griefing attacks low fixed

Description:
When an entry for a contest is submitted via the Contest.submitEntry function (Link) it can be cancelled as long as it's not approved via the Contest.cancelEntry function (Link).

Entries are indexed by an id that is determined by the user when the entry is created.

Different entries can have the same id if one entry is created, cancelled and then then the other entry with the same id is created.

This introduces race conditions between users and between users and the Spire.

Impact:
There are two basic scenarios.

Scenario 1:

  • User 1 wants to submit an entry with id x
  • User 2 front-runs User 1 and submits an entry with the same id, then cancels it after User 1's transaction has failed
  • This can go on multiple times such as to actually impact the availability of the Contest

Scenario 2:

  • User 1 has submitted an entry with id x
  • MID_LEVEL_ADMIN decides to approve it
  • User 1 front runs MID_LEVEL_ADMIN by cancelling the entry and submitting a new entry with another URI
  • an unintended URI is approved (This can lead to problems in later iterations of the protocol where approved but losing entries have further abilities. Currently this does not pose a significant risk because the MID_LEVEL_ADMIN can always just set the URI to any value manually)

The first Spire will be deployed on Arbitrum, which does not have a mempool.

Thereby the risk of front-running is mitigated.

However it has been stated by the client that the protocol should be chain-agnostic.

Recommendation:
It is recommended to implement a counter for the entry ids which gets incremented whenever an entry is created.

This ensures that no two entries have the same id and submitting an entry cannot be front-run.

Resolution:
The counter has been implemented and the Contest.submitEntry function now returns the id of the entry that is created.

Improvement Findings (34)

5. Use fixed pragma improvement fixed

Description:
All the Solidity files specify the Solidity version as ^0.8.0.

It is recommended to use a fixed Solidity version to avoid compilation with an unintended version.

The version that is specified in the Foundry configuration file is 0.8.17, so this version should be specified in the Solidity files as well.

Resolution:
The recommendation has been implemented, all Solidity files now use 0.8.17 as the Solidity version.

6. Remove unnecessary reentrancy guards improvement fixed

Description:
While reentrancy guards offer protection against reentrancy attacks, they introduce some overhead.

I recommend to remove the reentrancy guards in the following instances because there's obviously no way to reenter the function, i.e. there is no call to a component outside the protocol that could reenter.

Also consider that by removing reentrancy guards, some contracts may no longer need to inherit from ReentrancyGuard.

Instances:
https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/ToggleGovernanceFactory.sol#L14

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/ContestStaker.sol#L114

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/ContestStaker.sol#L134

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/ContestFactory.sol#L46

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/ContestFactory.sol#L50

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/ContestFactory.sol#L54

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/ContestFactory.sol#L73

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/Contest.sol#L123

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/Contest.sol#L172

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/Contest.sol#L186

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/Spire.sol#L175

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/Spire.sol#L145

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/Spire.sol#L155

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/Spire.sol#L166

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/Spire.sol#L171

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/Spire.sol#L185

Resolution:
The recommendation has been implemented.

7. Make public functions that are never called internally external improvement fixed

Description:
This improves readability and can save some gas due to how parameters for public and external functions are treated differently.

Reference:
https://gus-tavo-guim.medium.com/public-vs-external-functions-in-solidity-b46bcf0ba3ac

Instances:
https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/ToggleGovernance.sol#L31

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/ToggleGovernance.sol#L38

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/ToggleGovernance.sol#L46

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/ContestStaker.sol#L111

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/ContestStaker.sol#L131

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/ContestStaker.sol#L152

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/ContestStaker.sol#L176

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/ContestStaker.sol#L188

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/ContestStaker.sol#L220

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/ContestStaker.sol#L235

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/ContestStaker.sol#L239

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/ContestFactory.sol#L46

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/ContestFactory.sol#L50

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/ContestFactory.sol#L54

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/ContestFactory.sol#L58

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/ContestFactory.sol#L71

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/Spire.sol#L579

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/Contest.sol#L216

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/Contest.sol#L220

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/Contest.sol#L224

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/Contest.sol#L186

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/Spire.sol#L175

Resolution:
The recommendation has been implemented.

8. Events can use three indexed parameters improvement fixed

Description:
It is recommended to max out the number of indexed parameters for all events even if it's not considered necessary to make the parameters indexed.

Instances:
https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/Echoes.sol#L52

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/Spire.sol#L87

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/ToggleGovernance.sol#L23

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/ToggleGovernance.sol#L24

Resolution:
The recommendation has been implemented.

9. ConfigStore.getImplementationUint256: Result can be returned directly improvement fixed

Description:
In the ConfigStore.getImplementationUint256 function, the return value is saved to memory before it is returned. (Link)

Recommendation:
Return the result directly, without saving it to memory:

diff --git a/src/ConfigStore.sol b/src/ConfigStore.sol
index 15f9c0e..af092ad 100644
--- a/src/ConfigStore.sol
+++ b/src/ConfigStore.sol
@@ -54,7 +54,6 @@ contract ConfigStore is IConfigStore, Ownable, Multicall {
      */
     function getImplementationUint256(bytes32 interfaceName) external view returns (uint256) {
         address implementationAddress = interfacesImplemented[interfaceName];
-        uint256 implementationUint256 = uint256(uint160(implementationAddress));
-        return implementationUint256;
+        return uint256(uint160(implementationAddress));
     }
 }

Resolution:
The recommendation has been implemented.

10. Enforce override convention when implementing functions from interfaces improvement fixed

Description:
In the codebase, when a function from an interface is implemented, that function is marked with the override modifier.

I recommend to enforce this convention and to add the override modifier to where it is missing.

Instances:
https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/ConfigStore.sol#L55

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/ContestFactory.sol#L46

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/ContestFactory.sol#L50

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/ContestFactory.sol#L54

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/ConfigStore.sol#L55

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/Contest.sol#L198

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/Contest.sol#L236

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/Spire.sol#L175

Resolution:
The recommendation has been implemented.

11. Refactor AccessControl._setAdminContractAccess and AccessControl._setTeamContractAccess functions improvement fixed

Description:
The functions contain an unnecessary check for _revokedAdminAccess and _revokedTeamAccess respectively.

Both checks can only be reached when the respective variable is false, therefore its value does not have to be checked.

Recommendation:

diff --git a/src/AccessControl.sol b/src/AccessControl.sol
index 9a282b9..992c7ef 100644
--- a/src/AccessControl.sol
+++ b/src/AccessControl.sol
@@ -282,13 +282,13 @@ abstract contract AccessControl is Context, IAccessControl {
 
     function _setAdminContractAccess(bool access) internal {
         if (_revokedAdminAccess) revert("AccessControl: access denied");
-        _revokedAdminAccess = !_revokedAdminAccess ? !access : true;
+        _revokedAdminAccess = !access;
         _hasAdminAccess = access;
     }
 
     function _setTeamContractAccess(bool access) internal {
         if (_revokedTeamAccess) revert("AccessControl: access denied");
-        _revokedTeamAccess = !_revokedTeamAccess ? !access : true;
+        _revokedTeamAccess = !access;
         _hasTeamAccess = access;
     }
 }

Resolution:
The recommendation has been implemented.

12. ContestStaker: Use RemoveFromSetFailed error instead of RemoveToSetFailed error improvement fixed

Description:
It makes no sense to speak of "remove to a set".
The correct RemoveFromSetFailed error to replace it already exists but it's never used.
I recommend to replace the instances of RemoveToSetFailed with RemoveFromSetFailed and to remove the RemoveToSetFailed error from the contract entirely.

Recommendation:

diff --git a/src/ContestStaker.sol b/src/ContestStaker.sol
index fbef5c2..fc23d18 100644
--- a/src/ContestStaker.sol
+++ b/src/ContestStaker.sol
@@ -19,7 +19,6 @@ import { ReentrancyGuard } from "openzeppelin-contracts/contracts/security/Reent
 error UnregisteredContest();
 error AddToSetFailed();
 error RemoveFromSetFailed();
-error RemoveToSetFailed();
 error StakedContractNotExists();
 error TokenIdNotExists();
 error InvalidStakedContract();
@@ -266,7 +265,7 @@ contract ContestStaker is IContestStaker, ERC1155Holder, ReentrancyGuard, Multic
         uint256 len = tokenIds.length;
         for (uint256 i = 0; i < len; i++) {
             bool success = stakeableTokenIds[stakedContract].remove(tokenIds[i]);
-            if (!success) revert RemoveToSetFailed();
+            if (!success) revert RemoveFromSetFailed();
             emit RemovedTokenId(stakedContract, tokenIds[i]);
         }
         emit RemovedStakedContract(stakedContract);
@@ -278,7 +277,7 @@ contract ContestStaker is IContestStaker, ERC1155Holder, ReentrancyGuard, Multic
             revert TokenIdNotExists();
         }
         bool success = stakeableTokenIds[stakedContract].remove(tokenId);
-        if (!success) revert RemoveToSetFailed();
+        if (!success) revert RemoveFromSetFailed();
         emit RemovedTokenId(stakedContract, tokenId);
     }
 }

Resolution:
The recommendation has been implemented.

13. Remove unused errors improvement fixed

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/Contest.sol#L17

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/Echoes.sol#L16

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/Spire.sol#L20-L23

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/Spire.sol#L25-L26

Resolution:
The recommendation has been implemented.

14. Contest: Use of Address library is not necessary improvement fixed

Description:
There's no use of a function from the Address library.
Hence it is recommended to remove the following two lines:

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/Contest.sol#L7

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/Contest.sol#L46

Resolution:
The recommendation has been implemented.

15. Contest.acceptEntries: noWinner modifier is redundant improvement fixed

Description:
The noWinner modifier is redundant because there's also the check that the Contest must not be closed:

Link

    function acceptEntries(uint256[] memory entryIds) external override onlyOwner noWinner nonReentrant {
        if (isClosed()) revert Closed();
        uint256 newlyAcceptedEntries;
        ...

In all cases when there is a winner and noWinner reverts, the Contest is also closed.
Therefore I recommend to remove the noWinner modifier.

Note: Removing isClosed() instead of noWinner is not possible!

Resolution:
The recommendation has been implemented.

16. Contest.setWinningEntry: noWinner modifier is redundant improvement fixed

Description:
The Contest.setWinningEntry function has the noWinner modifier and also checks that winner.winner must be address(0). (Link)

Both are the same check, therefore it is recommended to remove one instance of the check.

Resolution:
The recommendation has been implemented.

17. Contest.reclaimEntry: Use EntryNotExists instead of NotEntrant error improvement fixed

Description:
The wrong error is used. The error that correctly describes the problem is EntryNotExists. (Link)

Resolution:
The recommendation has been implemented.

18. Optimize gas usage of loops improvement fixed

Description:
There are many instances of for-loops that can be optimized for Gas usage.

Clearly the client is aware of how to optimize for-loops for gas usage as can be seen in this instance.

There's just one additional improvement: Don't initialize the counter variable with 0.

Instances:
https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/ContestStaker.sol#L87-L92

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/ContestStaker.sol#L226-L228

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/ContestStaker.sol#L267-L271

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/Echoes.sol#L83-L86

echoCount[genesisTextId][chapterId] is checked to be 0, so i can just be defined as uint i wihout initialization.
https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/Echoes.sol#L101-L103

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/Echoes.sol#L197-L199

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/Echoes.sol#L204-L209

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/Echoes.sol#L187-L192

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/Echoes.sol#L165-L167

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/GenesisText.sol#L135-L140

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/Contest.sol#L126-L132

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/Spire.sol#L108-L111

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/Spire.sol#L147-L152

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/Spire.sol#L157-L162

Resolution:
The for-loops have been optimized for gas usage.
For some instances, further improvements could be made which were implemented in commit da65e29b6d637bb6a11999bf968c9e41538cab50 and commit b6d8fe4649fade25a16f7af9fb3fe80e6a66c0c9.

19. ContestStaker.unstake: Don't delete values in mapping improvement fixed

Description:
In the ContestStaker.unstake function, the delete keyword is used to clear the value in the stakes and frozenStakes mapping. (Link)

In case this code executes, both entries already have the value 0, so it's not necessary to clear them again.

The maximum gas consumption of the unstake function can be reduced by almost 800 Gas, according to the forge gas report.

Recommendation:
Remove the redundant code.

diff --git a/src/ContestStaker.sol b/src/ContestStaker.sol
index fbef5c2..cee62e7 100644
--- a/src/ContestStaker.sol
+++ b/src/ContestStaker.sol
@@ -191,11 +191,7 @@ contract ContestStaker is IContestStaker, ERC1155Holder, ReentrancyGuard, Multic
         }
         if (amount == 0) revert InvalidInputAmount();
         stakes[stakedContract][tokenId][msg.sender] -= amount;
-        // If stake is now 0, delete the entries to give caller gas refund.
-        if (stakes[stakedContract][tokenId][msg.sender] == 0) {
-            delete stakes[stakedContract][tokenId][msg.sender];
-            delete frozenStakes[stakedContract][tokenId][msg.sender];
-        }
+        
         IERC1155(address(stakedContract)).safeTransferFrom(address(this), msg.sender, tokenId, amount, "");
         emit Unstaked(stakedContract, tokenId, amount, msg.sender);
     }

Resolution:
The recommendation has been implemented.

20. ContestStaker.getStakeAmountsForUser: Use existing array to return result improvement fixed

Description:
In addition to optimizing the gas-usage of the loop, it is not necessary to create a second array to store the result.

The result can simply be written back into the existing allTokenIds array.

Recommendation:

diff --git a/src/ContestStaker.sol b/src/ContestStaker.sol
index fbef5c2..6dfc999 100644
--- a/src/ContestStaker.sol
+++ b/src/ContestStaker.sol
@@ -219,13 +219,13 @@ contract ContestStaker is IContestStaker, ERC1155Holder, ReentrancyGuard, Multic
     )
         public
         view
-        returns (uint256[] memory tokenIds)
+        returns (uint256[] memory)
     {
         uint256[] memory allTokenIds = getStakeableTokenIds(stakedContract);
-        tokenIds = new uint256[](allTokenIds.length);
         for (uint256 i = 0; i < allTokenIds.length; i++) {
-            tokenIds[i] = getUsableStake(stakedContract, allTokenIds[i], user);
+            allTokenIds[i] = getUsableStake(stakedContract, allTokenIds[i], user);
         }
+        return allTokenIds;
     }

Resolution:
The recommendation has been implemented.

21. ContestStaker._removeStakedContract and ContestStaker._removeStakeableTokenId functions contain redundant checks improvement fixed

Description:
The remove function from the EnumerableSet library only returns false if the value to be removed does not exist.

Since in both functions (ContestStaker._removeStakedContract and ContestStaker._removeStakeableTokenId) it is ensured that the value does in fact exist in the set, it is unnecessary to check the return value of remove.

Recommendation:
Remove the redundant checks.

diff --git a/src/ContestStaker.sol b/src/ContestStaker.sol
index fbef5c2..9b850ab 100644
--- a/src/ContestStaker.sol
+++ b/src/ContestStaker.sol
@@ -265,9 +265,7 @@ contract ContestStaker is IContestStaker, ERC1155Holder, ReentrancyGuard, Multic
         uint256[] memory tokenIds = stakeableTokenIds[stakedContract].values();
         uint256 len = tokenIds.length;
         for (uint256 i = 0; i < len; i++) {
-            bool success = stakeableTokenIds[stakedContract].remove(tokenIds[i]);
-            if (!success) revert RemoveToSetFailed();
-            emit RemovedTokenId(stakedContract, tokenIds[i]);
+            stakeableTokenIds[stakedContract].remove(tokenIds[i]);
         }
         emit RemovedStakedContract(stakedContract);
     }
@@ -277,8 +275,7 @@ contract ContestStaker is IContestStaker, ERC1155Holder, ReentrancyGuard, Multic
         if (!stakedContracts[stakedContract] || !stakeableTokenIds[stakedContract].contains(tokenId)) {
             revert TokenIdNotExists();
         }
-        bool success = stakeableTokenIds[stakedContract].remove(tokenId);
-        if (!success) revert RemoveToSetFailed();
+        stakeableTokenIds[stakedContract].remove(tokenId);
         emit RemovedTokenId(stakedContract, tokenId);
     }
 }

Resolution:
The recommendation has been implemented after further discussion in commit ab56b1a036a7d21a79506591f2abedc68ac5dd95.

22. Remove code for "additional echo contests" improvement fixed

Description:
"Additional echo contests" are echo contests beyond the initial five echo contests for each chapter.

As discussed with the client, these additional contests are not supported in this iteration of the protocol and it's not clear in which form - if at all - they will be supported in the future.

Therefore the unused code should be removed.

Note that there is no security impact to the existence of this half-baked functionality, as the affected functions can only be called by a trusted MID_LEVEL_ADMIN or beneficiary.

Code to remove:
https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/Spire.sol#L526-L577

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/Echoes.sol#L151-L168

Resolution:
The recommendation has been implemented.

23. ContestFactory.sol: Set Spire once upon construction improvement fixed

Description:
The ContestFactory contract has a setSpire function.
It is sufficient to set the Spire address once upon construction, it does never change. (Link)

Recommendation:
I propose the following changes to the ContestFactory contract:

diff --git a/src/ContestFactory.sol b/src/ContestFactory.sol
index 963bfa7..c7f03a7 100644
--- a/src/ContestFactory.sol
+++ b/src/ContestFactory.sol
@@ -38,10 +38,13 @@ contract ContestFactory is IContestFactory, ContestStaker, Ownable {
 
     constructor(
         address _stakedContract,
-        uint256[] memory _stakeableTokenIds
+        uint256[] memory _stakeableTokenIds,
+        address spire
     )
         ContestStaker(_stakedContract, _stakeableTokenIds) // solhint-disable-next-line no-empty-blocks
-    { }
+    { 
+        _spire = spire;
+    }
 
     function addStakeableTokenId(address stakedContract, uint256 tokenId) public onlyContract nonReentrant {
         _addStakeableTokenId(stakedContract, tokenId);
@@ -55,12 +58,6 @@ contract ContestFactory is IContestFactory, ContestStaker, Ownable {
         _removeStakeableTokenId(stakedContract, tokenId);
     }
 
-    function setSpire(address spire) public onlyOwner {
-        if (spire == address(0)) revert InvalidSpireAddress();
-        _spire = spire;
-        emit SetSpireAddress(_spire);
-    }
-
     // Anyone can call this function to deploy a new Contest that sets the caller as the owner of the new Contest.
     // This should be called by the Spire contract but there is no harm if anyone calls it.
     function deployNewContest(

The deployment order of the protocol must be changed as well, here are the changes that need to be made to CommonTestBase.t.sol:

diff --git a/test/CommonTestBase.t.sol b/test/CommonTestBase.t.sol
index 604f1de..5088ab2 100644
--- a/test/CommonTestBase.t.sol
+++ b/test/CommonTestBase.t.sol
@@ -43,9 +43,7 @@ contract CommonTestBase is PRBTest {
         configStore = new ConfigStore();
 
         stakedToken = new MockERC1155("");
-        contestFactory = new ContestFactory(address(stakedToken), stakeableTokenIds);
         toggleGovernanceFactory = new ToggleGovernanceFactory();
-        configStore.changeImplementationAddress(ConfigStoreInterfaces.CONTEST_FACTORY, address(contestFactory));
         configStore.changeImplementationAddress(ConfigStoreInterfaces.BENEFICIARY, address(beneficiary));
         configStore.changeImplementationAddress(ConfigStoreInterfaces.TEAM, address(beneficiary));
         configStore.changeImplementationAddress(ConfigStoreInterfaces.TEAM_PERCENT, address(25));
@@ -53,10 +51,13 @@ contract CommonTestBase is PRBTest {
             ConfigStoreInterfaces.TOGGLE_GOVERNOR_FACTORY, address(toggleGovernanceFactory)
         );
 
-        contestStaker = ContestStaker(contestFactory);
         spire = new SpireHarness(configStore);
+        contestFactory = new ContestFactory(address(stakedToken), stakeableTokenIds, spire);
+        contestStaker = ContestStaker(contestFactory);
+        configStore.changeImplementationAddress(ConfigStoreInterfaces.CONTEST_FACTORY, address(contestFactory));
+
+
         spire.init();
-        contestFactory.setSpire(address(spire));
         vm.stopPrank();
     }
 }

By applying these changes, it's also no longer necessary for ContestFactory to inherit from Ownable since the only use of the onlyOwner modifier has been removed.

Resolution:
The recommendation has been implemented.
In addition to simply initializing the _spire variable in the ContestFactory as in the recommendation, the check from the setSpire function has been implemented there as well.
Also the SetSpireAddress event is emitted.

24. Simplify ToggleGovernance.hasEnoughOpenGovernToggles function improvement fixed

Description:
The ToggleGovernance.hasEnoughOpenGovernToggles function (Link) is only called from within Spire._shouldSkipGenesisText (Link).

The call to ToggleGovernance.hasEnoughOpenGovernToggles can only be reached when flow == Flow.Chapter and nextChapterId[genesisTextId] < GlobalConstants.MAX_CHAPTER_COUNT, i.e. 0 <= nextChaperId[genesisTextId] < 100.

This means the requiredOpenToggles parameter for the ToggleGovernance.hasEnoughOpenGovernToggles function is in the range (1,100].

Since we know that governanceToken.totalSupply(governanceTokenId) = 100 (Genesis Text tokens are minted before the chapter flow can start advancing), the requiredOpenToggles > governanceToken.totalSupply(governanceTokenId) condition never evaluates to true.

Recommendation:
Based on the above considerations we can simplify the ToggleGovernance.hasEnoughOpenGovernToggles function like so:

diff --git a/src/ToggleGovernance.sol b/src/ToggleGovernance.sol
index afdba10..530413b 100644
--- a/src/ToggleGovernance.sol
+++ b/src/ToggleGovernance.sol
@@ -47,10 +47,6 @@ contract ToggleGovernance is ERC1155Holder, IToggleGovernance, ReentrancyGuard,
         // staked   = "closed" toggles
         // unstaked = "open" toggles
         // inital   = all of "open" toggles
-        // Threshold for staked amount is minimum of total supply and required stake.
-        uint256 threshold = requiredOpenToggles > governanceToken.totalSupply(governanceTokenId)
-            ? governanceToken.totalSupply(governanceTokenId)
-            : requiredOpenToggles;
-        return GlobalConstants.MAX_CHAPTER_COUNT - stakedAmount >= threshold;
+        return GlobalConstants.MAX_CHAPTER_COUNT - stakedAmount >= requiredOpenToggles;
     }
 }

Resolution:
The recommendation has been implemented.

25. Echoes._mintContestWinner: _teamPercent and teamWallet don't need to be saved to memory improvement fixed

Description:
Both _teamPercent and _teamWallet are only read once in this function.
Therefore it does not save Gas to write the result of _getTeamPercent() and _getTeamWallet() to memory variables.
Use _getTeamPercent() and _getTeamWallet() directly. (Link).

Resolution:
The recommendation has been implemented.

26. Echoes._mintContestWinner: Emit more meaningful value in MintContestReward event improvement fixed

Description:
In the MintContestReward event, GlobalConstants.CONTEST_REWARD_AMOUNT is emitted in the amount parameter.

This is not a meaningful value as it is a constant and it's not the value that is actually minted to the winner. It is recommended to emit GlobalConstants.CONTEST_REWARD_AMOUNT - _teamCount instead. (Link).

Resolution:
The recommendation has been implemented.

27. Echoes: _setInitialEchoContestWinners function should be removed improvement fixed

Description:
The Echoes._setInitialEchoContestWinners function (Link) just passes it's arguments on to the Echoes._setEchoContestWinnner function without providing any additional abstraction.

Apparently Echoes._setInitialEchoContestWinners is a remnant of a previous iteration of the protocol and it's no longer needed.

The function is only called in Spire._setLatestEchoContestWinners.

Instead, Spire._setLatestEchoContestWinners should call Echoes._setEchoContestWinner directly.

diff --git a/src/Echoes.sol b/src/Echoes.sol
index d14833d..39be41e 100644
--- a/src/Echoes.sol
+++ b/src/Echoes.sol
@@ -103,21 +103,6 @@ contract Echoes is HasConfigStore, ERC1155Supply {
         }
     }
 
-    // Reverts if there isn't exactly one winning ID per initial echo contest.
-    // Will skip any contest ID's that have already set a winner or can't select a winner yet.
-    // Will revert if winning ID is not an actual entry ID for any of the contests that we can set a winner for.
-    function _setInitialEchoContestWinners(
-        uint256 genesisTextId,
-        uint256 chapterId,
-        uint256 echoId,
-        uint256 winningId,
-        uint256[] memory losingEntryIds
-    )
-        internal
-    {
-        _setEchoContestWinner(genesisTextId, chapterId, echoId, winningId, losingEntryIds);
-    }
-
     // Approve entryIds for echo ID.
     function _approveEchoContestEntries(
         uint256 genesisTextId,
diff --git a/src/Spire.sol b/src/Spire.sol
index cba361f..f743505 100644
--- a/src/Spire.sol
+++ b/src/Spire.sol
@@ -518,7 +518,7 @@ contract Spire is ISpire, GenesisText, ERC1155Holder, AccessControl, Ownable, Re
     )
         internal
     {
-        _setInitialEchoContestWinners(
+        _setEchoContestWinner(
             genesisTextId, _latestEchoChapterId(genesisTextId), echoId, winningId, losingEntryIds
         );
     }

Resolution:
The recommendation has been implemented.
In the call to _setEchoContestWinner, an additional toBeneficiary parameter is passed which is in line with the recommendation for Finding 1.

28. Remove unused imports improvement fixed

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/Spire.sol#L11

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/GenesisText.sol#L9-L11

Resolution:
The recommendation has been implemented.

29. Remove unused events improvement fixed

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/Spire.sol#L81-L84

Resolution:
The recommendation has been implemented.

30. Remove unused modifiers improvement fixed

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/Spire.sol#L76-L79

Resolution:
The recommendation has been implemented.

31. Variables can be constants improvement fixed

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/Spire.sol#L52-L53

Resolution:
The recommendation has been implemented.

32. Function can be declared as view improvement fixed

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/Spire.sol#L175-L177

Resolution:
The recommendation has been implemented.

33. Rename Spire.setEchoContestWinners to Spire.setEchoContestWinner improvement fixed

Description:
The name of this function is a leftover from a previous iteration of the protocol.
setEchoContestWinner more accurately describes the behavior of the function.

https://github.com/Hyy-pe/Spire-Contracts/blob/665b6ab119dd1ded4f388f39d5164823f4735dfb/src/Spire.sol#L200

Note that there are downstream functions which have the same naming mistake.
Rename those as well even though they're not public.

Resolution:
The recommendation has been implemented.

34. Echoes: _initialEchoContestsHaveWinners function can be simplified improvement fixed

Description:
It is only necessary to perform the chapterEchoes[genesisTextId][chapterId][i] == address(0) check once (for any of the initial echo contests).

Either all initial echo contests exist or they don't.

diff --git a/src/Echoes.sol b/src/Echoes.sol
index d14833d..9988075 100644
--- a/src/Echoes.sol
+++ b/src/Echoes.sol
@@ -201,11 +201,11 @@ contract Echoes is HasConfigStore, ERC1155Supply {
     }
 
     function _initialEchoContestsHaveWinners(uint256 genesisTextId, uint256 chapterId) internal view returns (bool) {
+        if (chapterEchoes[genesisTextId][chapterId][0] == address(0)) {
+            return false;
+        }
         for (uint256 i = 0; i < GlobalConstants.INITIAL_ECHO_COUNT; i++) {
-            if (
-                chapterEchoes[genesisTextId][chapterId][i] == address(0)
-                    || !IContest(chapterEchoes[genesisTextId][chapterId][i]).hasWinner()
-            ) return false;
+            if (!IContest(chapterEchoes[genesisTextId][chapterId][i]).hasWinner()) return false;
         }
         return true;
     }

Resolution:
The recommendation has been implemented.

35. Spire.setEchoContestWinners and Spire.setChapterContestWinner: Gas optimization suggestions improvement fixed

Description:
Some execution paths in the Spire.setEchoContestWinners and Spire.setChapterContestWinner functions require that the flow IDs are read multiple times from storage.

Saving the flow IDs to memory instead will save Gas when averaged over multiple function calls.

diff --git a/src/Spire.sol b/src/Spire.sol
index cba361f..cf8e856 100644
--- a/src/Spire.sol
+++ b/src/Spire.sol
@@ -207,14 +207,16 @@ contract Spire is ISpire, GenesisText, ERC1155Holder, AccessControl, Ownable, Re
         onlyRole(GlobalConstants.MID_ADMIN_ROLE)
         nonReentrant
     {
+        uint256 previousFlow = previousEchoFlowId;
+        uint256 currentFlow = currentEchoFlowId;
         // Can only set current or previous echo contest winners.
-        if (genesisTextId != previousEchoFlowId && genesisTextId != currentEchoFlowId) {
+        if (genesisTextId != previousFlow && genesisTextId != currentFlow) {
             revert InvalidGenesisTextIdSetEchoContestWinner();
         }
 
         // Must set previous echo contest winner before current one.
-        if (genesisTextId == currentEchoFlowId && currentEchoFlowId != previousEchoFlowId) {
-            if (!_latestEchoContestsHaveWinners(previousEchoFlowId)) revert MustSetPreviousEchoContestWinners();
+        if (genesisTextId == currentFlow && currentFlow != previousFlow) {
+            if (!_latestEchoContestsHaveWinners(previousFlow)) revert MustSetPreviousEchoContestWinners();
         }
         _setLatestEchoContestWinners(genesisTextId, winningId, echoId, losingEntryIds);
         _advanceEchoFlowIfPossible();
@@ -230,14 +232,16 @@ contract Spire is ISpire, GenesisText, ERC1155Holder, AccessControl, Ownable, Re
         onlyRole(GlobalConstants.MID_ADMIN_ROLE)
         nonReentrant
     {
+        uint256 previousFlow = previousChapterFlowId;
+        uint256 currentFlow = currentChapterFlowId;
         // Can only set current or previous chapter contest winners.
-        if (genesisTextId != previousChapterFlowId && genesisTextId != currentChapterFlowId) {
+        if (genesisTextId != previousFlow && genesisTextId != currentFlow) {
             revert InvalidGenesisTextIdSetChapterContestWinner();
         }
 
         // Must set previous chapter contest winner before current one.
-        if (genesisTextId == currentChapterFlowId && currentChapterFlowId != previousChapterFlowId) {
-            if (!_latestChapterContestHasWinner(previousChapterFlowId)) revert MustSetPreviousChapterContestWinner();
+        if (genesisTextId == currentFlow && currentFlow != previousFlow) {
+            if (!_latestChapterContestHasWinner(previousFlow)) revert MustSetPreviousChapterContestWinner();
         }
         _setLatestChapterContestWinner(genesisTextId, winningId, losingEntryIds);
         _advanceChapterFlowIfPossible();

Resolution:
The recommendation has been implemented.

36. Spire._advanceEchoFlowIfPossible and Spire._advanceChapterFlowIfPossible: Gas optimization suggestions improvement fixed

Description:
nextEchoChapterId[newCurrentFlowId] does not need to be saved to memory, it's only ever read once.

Use the newCurrentFlowId memory variable instead of the currentEchoFlowId storage variable when calling _createNextEchoContests.

Use the newCurrentFlowId memory variable instead of the currentChapterFlowId storage variable when calling _createNextChapterContest.

diff --git a/src/Spire.sol b/src/Spire.sol
index cba361f..c21d1db 100644
--- a/src/Spire.sol
+++ b/src/Spire.sol
@@ -292,8 +292,7 @@ contract Spire is ISpire, GenesisText, ERC1155Holder, AccessControl, Ownable, Re
         (bool canAdvance, uint256 newCurrentFlowId, uint256 newPreviousFlowId) =
             _advanceFlowIfPossible(previousEchoFlowId, currentEchoFlowId, Flow.Echo);
         if (canAdvance) {
-            uint256 _nextEchoChapterId = nextEchoChapterId[newCurrentFlowId];
-            if (_nextEchoChapterId > 0) {
+            if (nextEchoChapterId[newCurrentFlowId] > 0) {
                 uint256 latestEchoId = _latestEchoChapterId(newCurrentFlowId);
                 // The chapter contest should be created and must have set a winner before we can open its echoes.
                 if (!_hasCreatedChapterContest(newCurrentFlowId, latestEchoId)) {
@@ -309,7 +308,7 @@ contract Spire is ISpire, GenesisText, ERC1155Holder, AccessControl, Ownable, Re
             emit AdvancedEchoFlow(newPreviousFlowId, newCurrentFlowId);
 
             // If next genesis text hasn't opened its echo contests yet then open them.
-            _createNextEchoContests(currentEchoFlowId);
+            _createNextEchoContests(newCurrentFlowId);
         }
         return canAdvance;
     }
@@ -355,7 +354,7 @@ contract Spire is ISpire, GenesisText, ERC1155Holder, AccessControl, Ownable, Re
             previousChapterFlowId = newPreviousFlowId;
             emit AdvancedChapterFlow(newPreviousFlowId, newCurrentFlowId);
 
-            _createNextChapterContest(currentChapterFlowId);
+            _createNextChapterContest(newCurrentFlowId);
         }
         return canAdvance;
     }

Resolution:
The recommendation has been implemented.

37. Spire.approveChapterContestEntries and Spire.approveEchoContestEntries: Gas optimization suggestions improvement fixed

Description:
Save the current flow ID to memory instead of reading it from storage twice.

diff --git a/src/Spire.sol b/src/Spire.sol
index cba361f..b4f024e 100644
--- a/src/Spire.sol
+++ b/src/Spire.sol
@@ -396,7 +396,8 @@ contract Spire is ISpire, GenesisText, ERC1155Holder, AccessControl, Ownable, Re
         onlyRole(GlobalConstants.MID_ADMIN_ROLE)
         nonReentrant
     {
-        IContest(chapterContests[currentChapterFlowId][_latestChapterId(currentChapterFlowId)]).acceptEntries(entryIds);
+        uint256 currentChapterFlow = currentChapterFlowId;
+        IContest(chapterContests[currentChapterFlow][_latestChapterId(currentChapterFlow)]).acceptEntries(entryIds);
         _advanceChapterFlowIfPossible();
     }
 
@@ -416,7 +417,8 @@ contract Spire is ISpire, GenesisText, ERC1155Holder, AccessControl, Ownable, Re
         onlyRole(GlobalConstants.MID_ADMIN_ROLE)
         nonReentrant
     {
-        _approveEchoContestEntries(currentEchoFlowId, _latestEchoChapterId(currentEchoFlowId), echoId, entryIds);
+        uint256 currentEchoFlow = currentEchoFlowId;
+        _approveEchoContestEntries(currentEchoFlow, _latestEchoChapterId(currentEchoFlow), echoId, entryIds);
         _advanceEchoFlowIfPossible();
     }

Resolution:
The recommendation has been implemented.

38. Spire: Echo flow and chapter flow are not time-efficient improvement fixed

Description:
The echo flow and the chapter flow should be independent as much as possible.

The initial echo contests for a chapter (I will refer to them from now on as a single "echo contest") can be opened whenver the chapter contest has set a winner.

And a new chapter contest for a genesis text can be opened when the echo contest for the previous chapter contest of the same genesis text has set a winner.

(There are more conditions for opening contests, but these are the one's of interest)

Both the echo flow and the chapter flow might not be able to advance and open a new contest even though it's possible to do so.

Impact:
This finding is only rated as an Improvement because this behavior is not clearly wrong, it's simply a time-inefficieny.

In other words it might take longer for new contests to open (both chapter contests and echo contests) than is necessary.

This cannot lead to the Spire getting stuck however as is the case with the two High severity findings.

A scenario in which the echo flow cannot advance is illustrated in the following test:

// add test to Spire.t.sol
function test_echoFlowDoesNotFindNextChapter() public {
    // initialize genesis echoes
    // close and select winner for all genesis echo contests except last one (5)
    uint256 winningId = spireUtils.completeGenesisEchoes(0);

    for (uint256 i = 0; i < GlobalConstants.INITIAL_ECHO_COUNT; i++) {
        vm.prank(spire.owner());
        spire.setEchoContestWinners(0, winningId, i, new uint256[](0));
    }

    winningId = spireUtils.completeGenesisEchoes(1);

    for (uint256 i = 0; i < GlobalConstants.INITIAL_ECHO_COUNT; i++) {
        vm.prank(spire.owner());
        spire.setEchoContestWinners(1, winningId, i, new uint256[](0));
    }

    winningId = spireUtils.completeGenesisEchoes(2);

    for (uint256 i = 0; i < GlobalConstants.INITIAL_ECHO_COUNT; i++) {
        vm.prank(spire.owner());
        spire.setEchoContestWinners(2, winningId, i, new uint256[](0));
    }

    winningId = spireUtils.completeGenesisEchoes(3);

    for (uint256 i = 0; i < GlobalConstants.INITIAL_ECHO_COUNT; i++) {
        vm.prank(spire.owner());
        spire.setEchoContestWinners(3, winningId, i, new uint256[](0));
    }

    winningId = spireUtils.completeGenesisEchoes(4);

    for (uint256 i = 0; i < GlobalConstants.INITIAL_ECHO_COUNT; i++) {
        vm.prank(spire.owner());
        spire.setEchoContestWinners(4, winningId, i, new uint256[](0));
    }

    winningId = spireUtils.completeGenesisEchoes(5);

    console.log(Contest(spire.chapterEchoes(0,0,0)).hasWinner());
    console.log(Contest(spire.chapterEchoes(1,0,0)).hasWinner());
    console.log(Contest(spire.chapterEchoes(2,0,0)).hasWinner());
    console.log(Contest(spire.chapterEchoes(3,0,0)).hasWinner());
    console.log(Contest(spire.chapterEchoes(4,0,0)).hasWinner());
    console.log(Contest(spire.chapterEchoes(5,0,0)).hasWinner());



    // skip chapter contests for genesis texts 0 and 1
    ToggleGovernance tg0 = ToggleGovernance(spire.genesisGovernors(0));
    vm.startPrank(spire.owner());
    spire.setApprovalForAll(address(tg0), true);
    tg0.stake(GlobalConstants.MAX_CHAPTER_COUNT);
    vm.stopPrank();
    assertTrue(spire.shouldSkipGenesisText(0, Spire.Flow.Chapter));
    
    ToggleGovernance tg1 = ToggleGovernance(spire.genesisGovernors(1));
    vm.startPrank(spire.owner());
    spire.setApprovalForAll(address(tg1), true);
    tg1.stake(GlobalConstants.MAX_CHAPTER_COUNT);
    vm.stopPrank();
    assertTrue(spire.shouldSkipGenesisText(1, Spire.Flow.Chapter));
    
    
    
    console.log(IERC1155(address(spire)).balanceOf(spire.owner(),0));
    console.log(IERC1155(address(spire)).balanceOf(spire.owner(),1));
    console.log(IERC1155(address(spire)).balanceOf(spire.owner(),2));

    // initialize chapter flow at genesis text 2
    spire.initializeChapterFlow();
    console.log(spire.getChapterContests(0,0));
    console.log(spire.getChapterContests(1,0));
    console.log(spire.getChapterContests(2,0));
    


    // now skip all genesis texts apart from 1
    ToggleGovernance tg2 = ToggleGovernance(spire.genesisGovernors(2));
    vm.startPrank(spire.owner());
    spire.setApprovalForAll(address(tg2), true);
    tg2.stake(GlobalConstants.MAX_CHAPTER_COUNT);
    vm.stopPrank();
    assertTrue(spire.shouldSkipGenesisText(2, Spire.Flow.Chapter));

    ToggleGovernance tg3 = ToggleGovernance(spire.genesisGovernors(3));
    vm.startPrank(spire.owner());
    spire.setApprovalForAll(address(tg3), true);
    tg3.stake(GlobalConstants.MAX_CHAPTER_COUNT);
    vm.stopPrank();
    assertTrue(spire.shouldSkipGenesisText(3, Spire.Flow.Chapter));

    ToggleGovernance tg4 = ToggleGovernance(spire.genesisGovernors(4));
    vm.startPrank(spire.owner());
    spire.setApprovalForAll(address(tg4), true);
    tg4.stake(GlobalConstants.MAX_CHAPTER_COUNT);
    vm.stopPrank();
    assertTrue(spire.shouldSkipGenesisText(4, Spire.Flow.Chapter));

    ToggleGovernance tg5 = ToggleGovernance(spire.genesisGovernors(5));
    vm.startPrank(spire.owner());
    spire.setApprovalForAll(address(tg5), true);
    tg5.stake(GlobalConstants.MAX_CHAPTER_COUNT);
    vm.stopPrank();
    assertTrue(spire.shouldSkipGenesisText(5, Spire.Flow.Chapter));

    vm.startPrank(spire.owner());
    tg1.unstake(GlobalConstants.MAX_CHAPTER_COUNT);
    vm.stopPrank();
    assertFalse(spire.shouldSkipGenesisText(1, Spire.Flow.Chapter));

    // select winner for chapter contest at genesis text 2
    // next chapter contest is created for genesis text 1
    spireUtils.completeLatestChapterContest(2);
    vm.startPrank(spire.owner());
    spire.setChapterContestWinner(2, 0, new uint256[](0));
    vm.stopPrank();

    console.log(spire.getChapterContests(0,0));
    console.log(spire.getChapterContests(1,0));
    console.log(Contest(spire.getChapterContests(1,0)).hasWinner());
    console.log(spire.getChapterContests(2,0));
    console.log(Contest(spire.getChapterContests(2,0)).hasWinner());

    console.log(spire.previousEchoFlowId());
    console.log(spire.currentEchoFlowId());

    // select winner for genesis echo contest at genesis text 5
    for (uint256 i = 0; i < GlobalConstants.INITIAL_ECHO_COUNT; i++) {
        vm.prank(spire.owner());
        spire.setEchoContestWinners(5, winningId, i, new uint256[](0));
    }
    // echo flow cannot advance even though there is a suitable contest to advance to!!
    console.log(spire.previousEchoFlowId());
    console.log(spire.currentEchoFlowId());
}

Recommendation:
This finding has been discussed with the client at length.

It has been determined that in the case of the chapter flow having to wait for the echo flow, time efficiency is not the main concern.
The main concern is predictability with regards to which chapter contest opens next.

On the other hand, the client decided to adjust the logic of the echo flow.

Consider this scenario:
2023-08-29_10-46

Currently, the echo flow would have to wait for the chapter contest at genesis text 1 to select a winner.

The intended new behavior is this:
2023-08-29_21-20

Instead of waiting for the chapter contest at genesis text 1 to select a winner, the echo flow moves on to the next chapter contest that has selected a winner which is at genesis text 2.

The new behavior can be implemented by tightening the rules for not skipping genesis texts in the Spire._shouldSkipGenesisText function (Link).

diff --git a/src/Spire.sol b/src/Spire.sol
index cba361f..1e77d2b 100644
--- a/src/Spire.sol
+++ b/src/Spire.sol
@@ -426,7 +426,13 @@ contract Spire is ISpire, GenesisText, ERC1155Holder, AccessControl, Ownable, Re
         }
 
         if (flow == Flow.Echo) {
-            if (nextEchoChapterId[genesisTextId] == nextChapterId[genesisTextId]) {
+            uint256 _nextEchoChapterId = nextEchoChapterId[genesisTextId];
+
+            if (_nextEchoChapterId == 0) {
+                return false;
+            } else if (_hasCreatedChapterContest(genesisTextId, _nextEchoChapterId - 1) &&
+                IContest(chapterContests[genesisTextId][_latestChapterId(genesisTextId)]).hasWinner()) 
+            {
                 return false;
             } else {
                 return true;

The first echo contest for a genesis text can always open (_nextEchoChapterId == 0), otherwise we require that the chapter contest is created and has set a winner.

In addition it's possible to remove a case differentiation in the Spire._advanceFlowIfPossible function (Link) that has been introduced to handle a situation in which two genesis texts are open.

By applying the above changes, this is handled correctly by default.

diff --git a/src/Spire.sol b/src/Spire.sol
index cba361f..d4be0ee 100644
--- a/src/Spire.sol
+++ b/src/Spire.sol
@@ -268,21 +268,7 @@ contract Spire is ISpire, GenesisText, ERC1155Holder, AccessControl, Ownable, Re
 
         if (latestContestsHaveClosed && previousContestHasWinner) {
             canAdvance = true;
-            if (flow == Flow.Chapter) {
-                (newCurrentFlowId, newPreviousFlowId) = _incrementFlowCounter(currFlowId, prevFlowId, flow);
-            } else {
-                uint256 latestEchoId = _latestEchoChapterId(currFlowId);
-                if (
-                    _hasCreatedChapterContest(currFlowId, latestEchoId)
-                        && IContest(chapterContests[currFlowId][latestEchoId]).hasWinner()
-                ) {
-                    // A special case where chapter and echoe are at the same position
-                    newCurrentFlowId = currFlowId;
-                    newPreviousFlowId = currFlowId;
-                } else {
-                    (newCurrentFlowId, newPreviousFlowId) = _incrementFlowCounter(currFlowId, prevFlowId, flow);
-                }
-            }
+            (newCurrentFlowId, newPreviousFlowId) = _incrementFlowCounter(currFlowId, prevFlowId, flow);
         } else {
             canAdvance = false;
         }

Resolution:
The fix has been made exactly as recommended, both diffs have been applied.
Furthermore there are no conflicting changes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment