kleros/kleros-v2

View on GitHub
contracts/test/arbitration/staking-neo.ts

Summary

Maintainability
F
3 wks
Test Coverage
import { ethers, getNamedAccounts, network, deployments } from "hardhat";
import {
  PNK,
  RandomizerRNG,
  RandomizerMock,
  SortitionModuleNeo,
  KlerosCoreNeo,
  TestERC721,
  DisputeResolver,
} from "../../typechain-types";
import { expect } from "chai";
import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers";

/* eslint-disable no-unused-vars */
/* eslint-disable no-unused-expressions */

/************************************************************************************************
Neo should behave like an arbitrator when all the following conditions are met:
- maxStake is high enough, 
- totalMaxStaked is high enough, 
- the juror has a NFT
- the arbitrable is whitelisted

Otherwise it should behave like a Neo arbitrator.
************************************************************************************************/

// TODO: assert on sortition.totalStaked in happy case

describe("Staking", async () => {
  const ETH = (amount: number) => ethers.parseUnits(amount.toString());
  const PNK = ETH;

  const extraData = ethers.AbiCoder.defaultAbiCoder().encode(
    ["uint256", "uint256", "uint256"],
    [2, 3, 1] // courtId 2, minJurors 3, disputeKitId 1
  );

  let deployer: string;
  let juror: HardhatEthersSigner;
  let guardian: HardhatEthersSigner;
  let pnk: PNK;
  let core: KlerosCoreNeo;
  let sortition: SortitionModuleNeo;
  let rng: RandomizerRNG;
  let randomizer: RandomizerMock;
  let nft: TestERC721;
  let resolver: DisputeResolver;
  let balanceBefore: bigint;

  const deployUnhappy = async () => {
    ({ deployer } = await getNamedAccounts());

    await deployments.fixture(["ArbitrationNeo"], {
      fallbackToGlobal: true,
      keepExistingDeployments: false,
    });
    pnk = (await ethers.getContract("PNK")) as PNK;
    core = (await ethers.getContract("KlerosCoreNeo")) as KlerosCoreNeo;
    sortition = (await ethers.getContract("SortitionModuleNeo")) as SortitionModuleNeo;
    rng = (await ethers.getContract("RandomizerRNG")) as RandomizerRNG;
    randomizer = (await ethers.getContract("RandomizerOracle")) as RandomizerMock;
    resolver = (await ethers.getContract("DisputeResolverNeo")) as DisputeResolver;
    nft = (await ethers.getContract("KlerosV2NeoEarlyUser")) as TestERC721;

    // Juror signer setup and funding
    const { firstWallet } = await getNamedAccounts();
    juror = await ethers.getSigner(firstWallet);
    await pnk.transfer(juror.address, PNK(100_000));
    await ethers.getSigner(deployer).then((signer) => signer.sendTransaction({ to: juror.address, value: ETH(1) }));

    // Set new guardian
    const { secondWallet } = await getNamedAccounts();
    guardian = await ethers.getSigner(secondWallet);
    await ethers.getSigner(deployer).then((signer) => signer.sendTransaction({ to: guardian.address, value: ETH(1) }));
    await core.changeGuardian(guardian.address);
  };

  const deploy = async () => {
    await deployUnhappy();

    // Sets up the happy path for Neo
    await nft.safeMint(deployer);
    await nft.safeMint(juror.address);
    await sortition.changeMaxStakePerJuror(PNK(10_000));
    await sortition.changeMaxTotalStaked(PNK(20_000));
  };

  const createDisputeAndReachGeneratingPhaseFromStaking = async () => {
    const arbitrationCost = ETH(0.5);
    await resolver.createDisputeForTemplate(extraData, "", "", 2, { value: arbitrationCost });
    await reachGeneratingPhaseFromStaking();
  };

  const reachGeneratingPhaseFromStaking = async () => {
    await network.provider.send("evm_increaseTime", [3600]); // Wait for minStakingTime
    await network.provider.send("evm_mine");

    expect(await sortition.phase()).to.be.equal(0); // Staking
    await sortition.passPhase(); // Staking -> Generating
    expect(await sortition.phase()).to.be.equal(1); // Generating
  };

  const drawFromGeneratingPhase = async () => {
    expect(await sortition.phase()).to.be.equal(1); // Generating

    const lookahead = await sortition.rngLookahead();
    for (let index = 0; index < lookahead; index++) {
      await network.provider.send("evm_mine");
    }

    await randomizer.relay(rng.target, 0, ethers.randomBytes(32));
    await sortition.passPhase(); // Generating -> Drawing
    expect(await sortition.phase()).to.be.equal(2); // Drawing

    await core.draw(0, 10);
  };

  const drawAndReachStakingPhaseFromGenerating = async () => {
    await drawFromGeneratingPhase();

    await network.provider.send("evm_increaseTime", [3600]); // Ensures that maxDrawingTime has passed
    await network.provider.send("evm_mine");

    await sortition.passPhase(); // Drawing -> Staking
    expect(await sortition.phase()).to.be.equal(0); // Staking
  };

  /************************************************************************************************
    SHOULD BEHAVE LIKE A NEO ARBITRATOR
  ************************************************************************************************/

  describe("When arbitrable is not whitelisted", () => {
    before("Setup", async () => {
      await deployUnhappy();
      await core.changeArbitrableWhitelist(resolver.target, false);
    });

    it("Should fail to create a dispute", async () => {
      const arbitrationCost = ETH(0.5);
      await expect(
        resolver.createDisputeForTemplate(extraData, "", "", 2, { value: arbitrationCost })
      ).to.be.revertedWithCustomError(core, "ArbitrableNotWhitelisted");
    });
  });

  describe("When arbitrable is whitelisted", () => {
    before("Setup", async () => {
      await deployUnhappy();
      await core.changeArbitrableWhitelist(resolver.target, true);
    });

    it("Should create a dispute", async () => {
      const arbitrationCost = ETH(0.5);
      expect(await resolver.createDisputeForTemplate(extraData, "", "", 2, { value: arbitrationCost }))
        .to.emit(core, "DisputeCreation")
        .withArgs(1, resolver.target);
    });
  });

  describe("When juror has no NFT", async () => {
    before("Setup", async () => {
      await deployUnhappy();
    });

    it("Should not be able to stake", async () => {
      await pnk.connect(juror).approve(core.target, PNK(1000));
      await expect(core.connect(juror).setStake(1, PNK(1000))).to.be.revertedWithCustomError(
        core,
        "NotEligibleForStaking"
      );
    });
  });

  describe("When juror does have a NFT", async () => {
    before("Setup", async () => {
      await deployUnhappy();
      await nft.safeMint(juror.address);
    });

    it("Should be able to stake", async () => {
      await pnk.connect(juror).approve(core.target, PNK(1000));
      await expect(await core.connect(juror).setStake(1, PNK(1000)))
        .to.emit(sortition, "StakeSet")
        .withArgs(juror.address, 1, PNK(1000));
      expect(await sortition.totalStaked()).to.be.equal(PNK(1000));
    });
  });

  describe("When juror stakes less", async () => {
    beforeEach("Setup", async () => {
      await deployUnhappy();
      await nft.safeMint(juror.address);

      await pnk.connect(juror).approve(core.target, PNK(10_000));
      await core.connect(juror).setStake(1, PNK(1000));
    });

    describe("When stakes are NOT delayed", () => {
      it("Should be able to unstake", async () => {
        expect(await core.connect(juror).setStake(1, PNK(500)))
          .to.emit(sortition, "StakeSet")
          .withArgs(juror.address, 1, PNK(500));
        expect(await sortition.totalStaked()).to.be.equal(PNK(500));

        expect(await core.connect(juror).setStake(1, PNK(1001)))
          .to.emit(sortition, "StakeSet")
          .withArgs(juror.address, 1, PNK(1001));
        expect(await sortition.totalStaked()).to.be.equal(PNK(1001));

        expect(await core.connect(juror).setStake(1, PNK(0)))
          .to.emit(sortition, "StakeSet")
          .withArgs(juror.address, 1, PNK(0));
        expect(await sortition.totalStaked()).to.be.equal(PNK(0));
      });
    });

    describe("When stakes are delayed", () => {
      beforeEach("Setup", async () => {
        await createDisputeAndReachGeneratingPhaseFromStaking();
      });

      it("Should be able to unstake", async () => {
        expect(await core.connect(juror).setStake(1, PNK(0)))
          .to.emit(sortition, "StakeDelayedNotTransferred")
          .withArgs(juror.address, 1, PNK(0))
          .to.not.emit(sortition, "StakeSet");
        expect(await sortition.totalStaked()).to.be.equal(PNK(1000));
        await drawAndReachStakingPhaseFromGenerating();
        expect(await sortition.executeDelayedStakes(10))
          .to.emit(sortition, "StakeSet")
          .withArgs(juror.address, 1, PNK(0));
      });
    });
  });

  describe("When juror stakes more", async () => {
    beforeEach("Setup", async () => {
      await deployUnhappy();
      await nft.safeMint(juror.address);
    });

    describe("When totalStaked is low", async () => {
      describe("When stakes are NOT delayed", () => {
        it("Should not be able to stake more than maxStakePerJuror", async () => {
          await pnk.connect(juror).approve(core.target, PNK(5000));
          await expect(core.connect(juror).setStake(1, PNK(5000))).to.be.revertedWithCustomError(
            core,
            "StakingMoreThanMaxStakePerJuror"
          );
          expect(await sortition.totalStaked()).to.be.equal(PNK(0));
        });
      });

      describe("When stakes are delayed", () => {
        it("Should not be able to stake more than maxStakePerJuror", async () => {
          await createDisputeAndReachGeneratingPhaseFromStaking();
          await pnk.connect(juror).approve(core.target, PNK(5000));
          await expect(core.connect(juror).setStake(1, PNK(5000))).to.be.revertedWithCustomError(
            core,
            "StakingMoreThanMaxStakePerJuror"
          );
          expect(await sortition.totalStaked()).to.be.equal(PNK(0));
          await drawAndReachStakingPhaseFromGenerating();
          await expect(sortition.executeDelayedStakes(10)).to.revertedWith("No delayed stake to execute.");
          expect(await sortition.totalStaked()).to.be.equal(PNK(0));
        });

        it("Should be able to stake exactly maxStakePerJuror", async () => {
          await pnk.connect(juror).approve(core.target, PNK(5000));
          await core.connect(juror).setStake(1, PNK(1000));
          await createDisputeAndReachGeneratingPhaseFromStaking();
          expect(await core.connect(juror).setStake(1, PNK(2000)))
            .to.emit(sortition, "StakeDelayedAlreadyTransferred")
            .withArgs(juror.address, 1, PNK(2000))
            .to.not.emit(sortition, "StakeSet");
          expect(await sortition.totalStaked()).to.be.equal(PNK(1000));
          await drawAndReachStakingPhaseFromGenerating();
          expect(await sortition.executeDelayedStakes(10))
            .to.emit(sortition, "StakeSet")
            .withArgs(juror.address, 1, PNK(2000));
          expect(await sortition.totalStaked()).to.be.equal(PNK(2000));
        });
      });
    });

    describe("When totalStaked is close to maxTotalStaked", async () => {
      beforeEach("Setup", async () => {
        await sortition.changeMaxTotalStaked(PNK(3000));

        // deployer increases totalStaked to 2000
        await nft.safeMint(deployer);
        await pnk.approve(core.target, PNK(2000));
        await core.setStake(1, PNK(2000));
      });

      describe("When stakes are NOT delayed", () => {
        it("Should not be able to stake more than maxTotalStaked", async () => {
          await pnk.connect(juror).approve(core.target, PNK(2000));
          await expect(core.connect(juror).setStake(1, PNK(2000))).to.be.revertedWithCustomError(
            core,
            "StakingMoreThanMaxTotalStaked"
          );
          expect(await sortition.totalStaked()).to.be.equal(PNK(2000));
        });

        it("Should be able to stake exactly maxTotalStaked", async () => {
          await pnk.connect(juror).approve(core.target, PNK(1000));
          await expect(await core.connect(juror).setStake(1, PNK(1000)))
            .to.emit(sortition, "StakeSet")
            .withArgs(juror.address, 1, PNK(1000));
          expect(await sortition.totalStaked()).to.be.equal(PNK(3000));
        });
      });

      describe("When stakes are delayed", () => {
        beforeEach("Setup", async () => {
          await createDisputeAndReachGeneratingPhaseFromStaking();
        });

        it("Should not be able to stake more than maxTotalStaked", async () => {
          await pnk.connect(juror).approve(core.target, PNK(2000));
          await expect(core.connect(juror).setStake(1, PNK(2000))).to.be.revertedWithCustomError(
            core,
            "StakingMoreThanMaxTotalStaked"
          );
          expect(await sortition.totalStaked()).to.be.equal(PNK(2000));
          await drawAndReachStakingPhaseFromGenerating();
          await expect(sortition.executeDelayedStakes(10)).to.revertedWith("No delayed stake to execute.");
          expect(await sortition.totalStaked()).to.be.equal(PNK(2000));
        });

        it("Should be able to stake exactly maxTotalStaked", async () => {
          await pnk.connect(juror).approve(core.target, PNK(1000));
          await expect(await core.connect(juror).setStake(1, PNK(1000)))
            .to.emit(sortition, "StakeDelayedAlreadyTransferred")
            .withArgs(juror.address, 1, PNK(1000));
          expect(await sortition.totalStaked()).to.be.equal(PNK(2000)); // Not updated until the delayed stake is executed
          await drawAndReachStakingPhaseFromGenerating();
          await expect(await sortition.executeDelayedStakes(10))
            .to.emit(sortition, "StakeSet")
            .withArgs(juror.address, 1, PNK(1000));
          expect(await sortition.totalStaked()).to.be.equal(PNK(3000));
        });
      });
    });
  });

  /************************************************************************************************
    SHOULD BEHAVE LIKE A REGULAR ARBITRATOR
  ************************************************************************************************/

  describe("When not paused", () => {
    beforeEach("Setup", async () => {
      await deploy();
    });

    it("Should not allow anyone except the guardian or the governor to pause", async () => {
      await expect(core.connect(juror).pause()).to.be.revertedWithCustomError(core, "GuardianOrGovernorOnly");
    });

    it("Should allow the guardian to pause", async () => {
      expect(await core.connect(guardian).pause()).to.emit(core, "Paused");
      expect(await core.paused()).to.equal(true);
    });

    it("Should allow the governor to pause", async () => {
      expect(await core.pause()).to.emit(core, "Paused");
      expect(await core.paused()).to.equal(true);
    });
  });

  describe("When paused", () => {
    beforeEach("Setup", async () => {
      await deploy();

      await pnk.approve(core.target, PNK(2000));
      await core.setStake(1, PNK(500));

      await core.connect(guardian).pause();
    });

    it("Should allow only the governor to unpause", async () => {
      await expect(core.connect(guardian).unpause()).to.be.revertedWithCustomError(core, "GovernorOnly");
      expect(await core.unpause()).to.emit(core, "Unpaused");
      expect(await core.paused()).to.equal(false);
    });

    it("Should prevent stake increases", async () => {
      await expect(core.setStake(1, PNK(1000))).to.be.revertedWithCustomError(core, "WhenNotPausedOnly");
    });

    it("Should prevent stake decreases", async () => {
      await expect(core.setStake(1, PNK(0))).to.be.revertedWithCustomError(core, "WhenNotPausedOnly");
    });
  });

  describe("When outside the Staking phase", async () => {
    const createSubcourtStakeAndCreateDispute = async () => {
      expect(await sortition.phase()).to.be.equal(0); // Staking
      await core.createCourt(1, false, PNK(1000), 1000, ETH(0.1), 3, [0, 0, 0, 0], ethers.toBeHex(3), [1]); // Parent - general court, Classic dispute kit

      await pnk.approve(core.target, PNK(4000));
      await core.setStake(1, PNK(2000));
      await core.setStake(2, PNK(2000));
      expect(await sortition.getJurorCourtIDs(deployer)).to.be.deep.equal([1n, 2n]);

      const arbitrationCost = ETH(0.1) * 3n;
      await resolver.createDisputeForTemplate(extraData, "", "", 2, { value: arbitrationCost });
    };

    describe("When stake is increased once", async () => {
      before("Setup", async () => {
        await deploy();
        await createSubcourtStakeAndCreateDispute();
        await reachGeneratingPhaseFromStaking();
        balanceBefore = await pnk.balanceOf(deployer);
      });

      it("Should be outside the Staking phase", async () => {
        expect(await sortition.phase()).to.be.equal(1); // Generating
        expect(await sortition.getJurorBalance(deployer, 2)).to.be.deep.equal([PNK(4000), 0, PNK(2000), 2]);
      });

      describe("When stake is increased", () => {
        it("Should transfer PNK but delay the stake increase", async () => {
          expect(await sortition.delayedStakeWriteIndex()).to.be.equal(0);
          expect(await sortition.delayedStakeReadIndex()).to.be.equal(1);
          await pnk.approve(core.target, PNK(1000));
          expect(await sortition.latestDelayedStakeIndex(deployer, 2)).to.be.equal(0);
          await expect(core.setStake(2, PNK(3000)))
            .to.emit(sortition, "StakeDelayedAlreadyTransferred")
            .withArgs(deployer, 2, PNK(3000));
          expect(await sortition.getJurorBalance(deployer, 2)).to.be.deep.equal([PNK(5000), 0, PNK(2000), 2]); // stake does not change
        });

        it("Should transfer some PNK out of the juror's account", async () => {
          expect(await pnk.balanceOf(deployer)).to.be.equal(balanceBefore - PNK(1000)); // PNK is transferred out of the juror's account
        });

        it("Should store the delayed stake for later", async () => {
          expect(await sortition.latestDelayedStakeIndex(deployer, 2)).to.be.equal(1);
          expect(await sortition.delayedStakeWriteIndex()).to.be.equal(1);
          expect(await sortition.delayedStakeReadIndex()).to.be.equal(1);
          expect(await sortition.delayedStakes(1)).to.be.deep.equal([deployer, 2, PNK(3000), true]);
        });
      });

      describe("When the Phase passes back to Staking", () => {
        before("Setup", async () => {
          await drawAndReachStakingPhaseFromGenerating();
          balanceBefore = await pnk.balanceOf(deployer);
        });

        it("Should execute the delayed stakes", async () => {
          await expect(await sortition.executeDelayedStakes(10))
            .to.emit(sortition, "StakeSet")
            .withArgs(deployer, 2, PNK(3000))
            .to.not.emit(sortition, "StakeDelayedNotTransferred")
            .to.not.emit(sortition, "StakeDelayedAlreadyTransferred")
            .to.not.emit(sortition, "StakeDelayedAlreadyTransferredWithdrawn");
          expect(await sortition.getJurorBalance(deployer, 2)).to.be.deep.equal([
            PNK(5000),
            PNK(300), // we're the only juror so we are drawn 3 times
            PNK(3000),
            2,
          ]); // stake unchanged, delayed
          expect(await sortition.delayedStakeWriteIndex()).to.be.equal(1);
          expect(await sortition.delayedStakeReadIndex()).to.be.equal(2);
          expect(await sortition.delayedStakes(1)).to.be.deep.equal([ethers.ZeroAddress, 0, 0, false]); // the 1st delayed stake got deleted
          expect(await sortition.delayedStakes(2)).to.be.deep.equal([ethers.ZeroAddress, 0, 0, false]); // the 2nd delayed stake got deleted
          expect(await sortition.latestDelayedStakeIndex(deployer, 1)).to.be.equal(0); // no delayed stakes left
        });

        it("Should not transfer any PNK", async () => {
          expect(await pnk.balanceOf(deployer)).to.be.equal(balanceBefore); // No PNK transfer
        });
      });
    });

    describe("When stake is decreased once", async () => {
      before("Setup", async () => {
        await deploy();
        await createSubcourtStakeAndCreateDispute();
        await reachGeneratingPhaseFromStaking();
        balanceBefore = await pnk.balanceOf(deployer);
      });

      it("Should be outside the Staking phase", async () => {
        expect(await sortition.phase()).to.be.equal(1); // Generating
        expect(await sortition.getJurorBalance(deployer, 2)).to.be.deep.equal([PNK(4000), 0, PNK(2000), 2]);
      });

      describe("When stake is decreased", async () => {
        it("Should delay the stake decrease", async () => {
          expect(await sortition.delayedStakeWriteIndex()).to.be.equal(0);
          expect(await sortition.delayedStakeReadIndex()).to.be.equal(1);
          expect(await sortition.latestDelayedStakeIndex(deployer, 2)).to.be.equal(0);
          await expect(core.setStake(2, PNK(1000)))
            .to.emit(sortition, "StakeDelayedNotTransferred")
            .withArgs(deployer, 2, PNK(1000));
          expect(await sortition.getJurorBalance(deployer, 2)).to.be.deep.equal([PNK(4000), 0, PNK(2000), 2]); // stake unchanged, delayed
        });

        it("Should not transfer any PNK", async () => {
          expect(await pnk.balanceOf(deployer)).to.be.equal(balanceBefore); // No PNK transfer yet
        });

        it("Should store the delayed stake for later", async () => {
          expect(await sortition.latestDelayedStakeIndex(deployer, 2)).to.be.equal(1);
          expect(await sortition.delayedStakeWriteIndex()).to.be.equal(1);
          expect(await sortition.delayedStakeReadIndex()).to.be.equal(1);
          expect(await sortition.delayedStakes(1)).to.be.deep.equal([deployer, 2, PNK(1000), false]);
        });
      });

      describe("When the Phase passes back to Staking", () => {
        before("Setup", async () => {
          await drawAndReachStakingPhaseFromGenerating();
          balanceBefore = await pnk.balanceOf(deployer);
        });

        it("Should execute the delayed stakes by withdrawing PNK and reducing the stakes", async () => {
          await expect(await sortition.executeDelayedStakes(10))
            .to.emit(sortition, "StakeSet")
            .withArgs(deployer, 2, PNK(1000));
          expect(await sortition.getJurorBalance(deployer, 2)).to.be.deep.equal([
            PNK(3000),
            PNK(300), // we're the only juror so we are drawn 3 times
            PNK(1000),
            2,
          ]); // stake unchanged, delayed
          expect(await sortition.delayedStakeWriteIndex()).to.be.equal(1);
          expect(await sortition.delayedStakeReadIndex()).to.be.equal(2);
          expect(await sortition.delayedStakes(1)).to.be.deep.equal([ethers.ZeroAddress, 0, 0, false]); // the 1st delayed stake got deleted
          expect(await sortition.delayedStakes(2)).to.be.deep.equal([ethers.ZeroAddress, 0, 0, false]); // the 2nd delayed stake got deleted
          expect(await sortition.latestDelayedStakeIndex(deployer, 1)).to.be.equal(0); // no delayed stakes left
        });

        it("Should withdraw some PNK", async () => {
          expect(await pnk.balanceOf(deployer)).to.be.equal(balanceBefore + PNK(1000)); // No PNK transfer yet
        });
      });
    });

    describe("When stake is decreased then increased back", async () => {
      before("Setup", async () => {
        await deploy();
        await createSubcourtStakeAndCreateDispute();
        await reachGeneratingPhaseFromStaking();
        balanceBefore = await pnk.balanceOf(deployer);
      });

      it("Should be outside the Staking phase", async () => {
        expect(await sortition.phase()).to.be.equal(1); // Generating
        expect(await sortition.getJurorBalance(deployer, 2)).to.be.deep.equal([PNK(4000), 0, PNK(2000), 2]);
      });

      describe("When stake is decreased", async () => {
        it("Should delay the stake decrease", async () => {
          expect(await sortition.delayedStakeWriteIndex()).to.be.equal(0);
          expect(await sortition.delayedStakeReadIndex()).to.be.equal(1);
          expect(await sortition.latestDelayedStakeIndex(deployer, 2)).to.be.equal(0);
          await expect(core.setStake(2, PNK(1000)))
            .to.emit(sortition, "StakeDelayedNotTransferred")
            .withArgs(deployer, 2, PNK(1000));
          expect(await sortition.getJurorBalance(deployer, 2)).to.be.deep.equal([PNK(4000), 0, PNK(2000), 2]); // stake unchanged, delayed
        });

        it("Should not transfer any PNK", async () => {
          expect(await pnk.balanceOf(deployer)).to.be.equal(balanceBefore); // No PNK transfer yet
        });

        it("Should store the delayed stake for later", async () => {
          expect(await sortition.latestDelayedStakeIndex(deployer, 2)).to.be.equal(1);
          expect(await sortition.delayedStakeWriteIndex()).to.be.equal(1);
          expect(await sortition.delayedStakeReadIndex()).to.be.equal(1);
          expect(await sortition.delayedStakes(1)).to.be.deep.equal([deployer, 2, PNK(1000), false]);
        });
      });

      describe("When stake is increased back to the previous amount", () => {
        it("Should delay the stake increase", async () => {
          balanceBefore = await pnk.balanceOf(deployer);
          expect(await sortition.latestDelayedStakeIndex(deployer, 2)).to.be.equal(1);
          await expect(core.setStake(2, PNK(2000)))
            .to.emit(sortition, "StakeDelayedNotTransferred")
            .withArgs(deployer, 2, PNK(2000))
            .to.not.emit(sortition, "StakeDelayedAlreadyTransferredWithdrawn");
          expect(await sortition.getJurorBalance(deployer, 2)).to.be.deep.equal([PNK(4000), 0, PNK(2000), 2]); // stake unchanged, delayed
        });

        it("Should not transfer any PNK", async () => {
          expect(await pnk.balanceOf(deployer)).to.be.equal(balanceBefore); // No PNK transfer yet
        });

        it("Should store the delayed stake for later", async () => {
          expect(await sortition.latestDelayedStakeIndex(deployer, 2)).to.be.equal(2);
          expect(await sortition.delayedStakeWriteIndex()).to.be.equal(2);
          expect(await sortition.delayedStakeReadIndex()).to.be.equal(1);
          expect(await sortition.delayedStakes(1)).to.be.deep.equal([ethers.ZeroAddress, 0, 0, false]); // the 1st delayed stake got deleted
          expect(await sortition.delayedStakes(2)).to.be.deep.equal([deployer, 2, PNK(2000), false]);
        });
      });

      describe("When the Phase passes back to Staking", () => {
        before("Setup", async () => {
          await drawAndReachStakingPhaseFromGenerating();
          balanceBefore = await pnk.balanceOf(deployer);
        });

        it("Should execute the delayed stakes but the stakes should remain the same", async () => {
          await expect(await sortition.executeDelayedStakes(10))
            .to.emit(sortition, "StakeSet")
            .withArgs(deployer, 2, PNK(2000));
          expect(await sortition.getJurorBalance(deployer, 2)).to.be.deep.equal([
            PNK(4000),
            PNK(300), // we're the only juror so we are drawn 3 times
            PNK(2000),
            2,
          ]); // stake unchanged, delayed
          expect(await sortition.delayedStakeWriteIndex()).to.be.equal(2);
          expect(await sortition.delayedStakeReadIndex()).to.be.equal(3);
          expect(await sortition.delayedStakes(1)).to.be.deep.equal([ethers.ZeroAddress, 0, 0, false]); // the 1st delayed stake got deleted
          expect(await sortition.delayedStakes(2)).to.be.deep.equal([ethers.ZeroAddress, 0, 0, false]); // the 2nd delayed stake got deleted
          expect(await sortition.latestDelayedStakeIndex(deployer, 2)).to.be.equal(0); // no delayed stakes left
        });

        it("Should not transfer any PNK", async () => {
          expect(await pnk.balanceOf(deployer)).to.be.equal(balanceBefore); // No PNK transfer yet
        });
      });
    });

    describe("When stake is increased then decreased back", async () => {
      before("Setup", async () => {
        await deploy();
        await createSubcourtStakeAndCreateDispute();
        await reachGeneratingPhaseFromStaking();
        balanceBefore = await pnk.balanceOf(deployer);
      });

      it("Should be outside the Staking phase", async () => {
        expect(await sortition.phase()).to.be.equal(1); // Generating
        expect(await sortition.getJurorBalance(deployer, 2)).to.be.deep.equal([PNK(4000), 0, PNK(2000), 2]);
      });

      describe("When stake is increased", () => {
        it("Should transfer PNK but delay the stake increase", async () => {
          expect(await sortition.delayedStakeWriteIndex()).to.be.equal(0);
          expect(await sortition.delayedStakeReadIndex()).to.be.equal(1);
          await pnk.approve(core.target, PNK(1000));
          expect(await sortition.latestDelayedStakeIndex(deployer, 2)).to.be.equal(0);
          await expect(core.setStake(2, PNK(3000)))
            .to.emit(sortition, "StakeDelayedAlreadyTransferred")
            .withArgs(deployer, 2, PNK(3000));
          expect(await sortition.getJurorBalance(deployer, 2)).to.be.deep.equal([PNK(5000), 0, PNK(2000), 2]); // stake does not change
        });

        it("Should transfer some PNK out of the juror's account", async () => {
          expect(await pnk.balanceOf(deployer)).to.be.equal(balanceBefore - PNK(1000)); // PNK is transferred out of the juror's account
        });

        it("Should store the delayed stake for later", async () => {
          expect(await sortition.latestDelayedStakeIndex(deployer, 2)).to.be.equal(1);
          expect(await sortition.delayedStakeWriteIndex()).to.be.equal(1);
          expect(await sortition.delayedStakeReadIndex()).to.be.equal(1);
          expect(await sortition.delayedStakes(1)).to.be.deep.equal([deployer, 2, PNK(3000), true]);
        });
      });

      describe("When stake is decreased back to the previous amount", () => {
        it("Should cancel out the stake decrease back", async () => {
          balanceBefore = await pnk.balanceOf(deployer);
          expect(await sortition.latestDelayedStakeIndex(deployer, 2)).to.be.equal(1);
          await expect(core.setStake(2, PNK(2000)))
            .to.emit(sortition, "StakeDelayedAlreadyTransferredWithdrawn")
            .withArgs(deployer, 2, PNK(1000))
            .to.emit(sortition, "StakeDelayedNotTransferred")
            .withArgs(deployer, 2, PNK(2000));
          expect(await sortition.getJurorBalance(deployer, 2)).to.be.deep.equal([PNK(4000), 0, PNK(2000), 2]); // stake has changed immediately
        });

        it("Should transfer back some PNK to the juror", async () => {
          expect(await pnk.balanceOf(deployer)).to.be.equal(balanceBefore + PNK(1000)); // PNK is sent back to the juror
        });

        it("Should store the delayed stake for later", async () => {
          expect(await sortition.latestDelayedStakeIndex(deployer, 2)).to.be.equal(2);
          expect(await sortition.delayedStakeWriteIndex()).to.be.equal(2);
          expect(await sortition.delayedStakeReadIndex()).to.be.equal(1);
          expect(await sortition.delayedStakes(1)).to.be.deep.equal([ethers.ZeroAddress, 0, 0, false]); // the 1st delayed stake got deleted
          expect(await sortition.delayedStakes(2)).to.be.deep.equal([deployer, 2, PNK(2000), false]);
        });
      });

      describe("When the Phase passes back to Staking", () => {
        before("Setup", async () => {
          await drawAndReachStakingPhaseFromGenerating();
          balanceBefore = await pnk.balanceOf(deployer);
        });

        it("Should execute the delayed stakes but the stakes should remain the same", async () => {
          await expect(sortition.executeDelayedStakes(10))
            .to.emit(await sortition, "StakeSet")
            .withArgs(deployer, 2, PNK(2000))
            .to.not.emit(sortition, "StakeDelayedNotTransferred")
            .to.not.emit(sortition, "StakeDelayedAlreadyTransferred")
            .to.not.emit(sortition, "StakeDelayedAlreadyTransferredWithdrawn");
          expect(await sortition.getJurorBalance(deployer, 2)).to.be.deep.equal([
            PNK(4000),
            PNK(300), // we're the only juror so we are drawn 3 times
            PNK(2000),
            2,
          ]); // stake unchanged, delayed
          expect(await sortition.delayedStakeWriteIndex()).to.be.equal(2);
          expect(await sortition.delayedStakeReadIndex()).to.be.equal(3);
          expect(await sortition.delayedStakes(1)).to.be.deep.equal([ethers.ZeroAddress, 0, 0, false]); // the 1st delayed stake got deleted
          expect(await sortition.delayedStakes(2)).to.be.deep.equal([ethers.ZeroAddress, 0, 0, false]); // the 2nd delayed stake got deleted
          expect(await sortition.latestDelayedStakeIndex(deployer, 2)).to.be.equal(0); // no delayed stakes left
        });

        it("Should not transfer any PNK", async () => {
          expect(await pnk.balanceOf(deployer)).to.be.equal(balanceBefore); // No PNK transfer yet
        });
      });
    });
  });

  describe("When a juror is inactive", async () => {
    before("Setup", async () => {
      await deploy();
    });

    it("Should unstake from all courts", async () => {
      await core.createCourt(1, false, PNK(1000), 1000, ETH(0.1), 3, [0, 0, 0, 0], ethers.toBeHex(3), [1]); // Parent - general court, Classic dispute kit

      await pnk.approve(core.target, PNK(4000));
      await core.setStake(1, PNK(2000));
      await core.setStake(2, PNK(2000));
      expect(await sortition.getJurorCourtIDs(deployer)).to.be.deep.equal([1, 2]);

      await createDisputeAndReachGeneratingPhaseFromStaking();
      await drawAndReachStakingPhaseFromGenerating();

      await core.passPeriod(0); // Evidence -> Voting
      await core.passPeriod(0); // Voting -> Appeal
      await core.passPeriod(0); // Appeal -> Execution

      expect(await sortition.getJurorCourtIDs(deployer)).to.be.deep.equal([1, 2]);
      await core.execute(0, 0, 1); // 1 iteration should unstake from both courts
      expect(await sortition.getJurorCourtIDs(deployer)).to.be.deep.equal([]);
    });
  });
});