cyberia-token/test/security/TimelockBoundary.test.ts

import { expect } from "chai";
import { ethers, upgrades } from "hardhat";
import { CAPToken } from "../typechain-types";
import { Signer } from "ethers";
import { time } from "@nomicfoundation/hardhat-network-helpers";

describe("Timelock Boundary Tests", function () {
  let cap: CAPToken;
  let owner: Signer;
  let treasury: Signer;
  let user1: Signer;

  const TIMELOCK_DELAY = 24 * 60 * 60; // 24 hours in seconds

  beforeEach(async function () {
    [owner, treasury, user1] = await ethers.getSigners();

    const CAP = await ethers.getContractFactory("CAPToken");
    cap = (await upgrades.deployProxy(CAP, [owner.address, treasury.address], {
      kind: "uups",
      initializer: "initialize",
    })) as unknown as CAPToken;
  });

  describe("Timelock Delay Boundaries", function () {
    it("Should reject application exactly 1 second before timelock expiry", async function () {
      await cap.connect(owner).proposeTaxChange(200, 300, 50);

      const targetTime = await cap.taxChangeTimestamp();

      // Set time to exactly 1 second before the target time
      await time.setNextBlockTimestamp(targetTime.sub(1));

      await expect(cap.connect(owner).applyTaxChange()).to.be.revertedWith("TIMELOCK_NOT_EXPIRED");
    });

    it("Should allow application exactly at timelock expiry", async function () {
      await cap.connect(owner).proposeTaxChange(200, 300, 50);

      // Move to exactly the timelock delay
      await time.increase(TIMELOCK_DELAY);

      await expect(cap.connect(owner).applyTaxChange()).to.not.be.reverted;

      expect(await cap.transferTaxBp()).to.equal(200);
    });

    it("Should allow application 1 second after timelock expiry", async function () {
      await cap.connect(owner).proposeTaxChange(200, 300, 50);

      // Move to 1 second after timelock delay
      await time.increase(TIMELOCK_DELAY + 1);

      await expect(cap.connect(owner).applyTaxChange()).to.not.be.reverted;

      expect(await cap.transferTaxBp()).to.equal(200);
    });

    it("Should handle application hours after timelock expiry", async function () {
      await cap.connect(owner).proposeTaxChange(200, 300, 50);

      // Move to 48 hours (well past timelock)
      await time.increase(TIMELOCK_DELAY * 2);

      await expect(cap.connect(owner).applyTaxChange()).to.not.be.reverted;

      expect(await cap.transferTaxBp()).to.equal(200);
    });

    it("Should handle application days after timelock expiry", async function () {
      await cap.connect(owner).proposeTaxChange(200, 300, 50);

      // Move to 7 days after proposal
      await time.increase(TIMELOCK_DELAY * 7);

      await expect(cap.connect(owner).applyTaxChange()).to.not.be.reverted;

      expect(await cap.transferTaxBp()).to.equal(200);
    });
  });

  describe("Multiple Proposal Scenarios", function () {
    it("Should handle rapid proposal replacements", async function () {
      // First proposal
      await cap.connect(owner).proposeTaxChange(100, 150, 25);
      const timestamp1 = await cap.taxChangeTimestamp();

      // Wait 1 hour
      await time.increase(3600);

      // Second proposal (overwrites first)
      await cap.connect(owner).proposeTaxChange(200, 250, 50);
      const timestamp2 = await cap.taxChangeTimestamp();

      expect(timestamp2).to.be.gt(timestamp1);

      // Wait 23 hours (total 24 hours from second proposal)
      await time.increase(23 * 3600);

      // Should NOT be able to apply yet (only 23 hours from second proposal)
      await expect(cap.connect(owner).applyTaxChange()).to.be.revertedWith("TIMELOCK_NOT_EXPIRED");

      // Wait 1 more hour
      await time.increase(3600);

      // Now should work with SECOND proposal values
      await cap.connect(owner).applyTaxChange();

      expect(await cap.transferTaxBp()).to.equal(200);
      expect(await cap.sellTaxBp()).to.equal(250);
      expect(await cap.buyTaxBp()).to.equal(50);
    });

    it("Should allow proposing same values multiple times", async function () {
      const values = { transfer: 200, sell: 300, buy: 50 };

      // First proposal
      await cap.connect(owner).proposeTaxChange(values.transfer, values.sell, values.buy);
      const timestamp1 = await cap.taxChangeTimestamp();

      // Wait a bit
      await time.increase(3600);

      // Propose same values again
      await cap.connect(owner).proposeTaxChange(values.transfer, values.sell, values.buy);
      const timestamp2 = await cap.taxChangeTimestamp();

      expect(timestamp2).to.be.gt(timestamp1);
      expect(await cap.pendingTransferTaxBp()).to.equal(values.transfer);
    });

    it("Should handle proposal at timestamp boundaries", async function () {
      // Get current block timestamp before proposal
      const tx = await cap.connect(owner).proposeTaxChange(200, 300, 50);
      const receipt = await tx.wait();
      const block = await ethers.provider.getBlock(receipt!.blockNumber);
      const blockTime = block!.timestamp;

      const proposalTime = await cap.taxChangeTimestamp();

      // Proposal timestamp should be block timestamp + delay
      expect(proposalTime).to.equal(blockTime + TIMELOCK_DELAY);
    });
  });

  describe("Timestamp Edge Cases", function () {
    it("Should handle timelock at maximum safe timestamp", async function () {
      await cap.connect(owner).proposeTaxChange(200, 300, 50);

      // Move to far future (but within safe bounds)
      const farFuture = TIMELOCK_DELAY + 365 * 24 * 60 * 60; // 1 year from now
      await time.increase(farFuture);

      await expect(cap.connect(owner).applyTaxChange()).to.not.be.reverted;
    });

    it("Should prevent double application of same proposal", async function () {
      await cap.connect(owner).proposeTaxChange(200, 300, 50);

      await time.increase(TIMELOCK_DELAY + 1);

      // First application
      await cap.connect(owner).applyTaxChange();

      // Second application should fail (no pending change)
      await expect(cap.connect(owner).applyTaxChange()).to.be.revertedWith("NO_PENDING_CHANGE");
    });

    it("Should handle proposal-apply-proposal cycle", async function () {
      // First cycle
      await cap.connect(owner).proposeTaxChange(200, 300, 50);
      await time.increase(TIMELOCK_DELAY + 1);
      await cap.connect(owner).applyTaxChange();

      expect(await cap.transferTaxBp()).to.equal(200);

      // Second cycle immediately after first
      await cap.connect(owner).proposeTaxChange(150, 200, 25);
      await time.increase(TIMELOCK_DELAY + 1);
      await cap.connect(owner).applyTaxChange();

      expect(await cap.transferTaxBp()).to.equal(150);
    });
  });

  describe("Tax Change Cancellation", function () {
    it("Should allow owner to cancel pending tax change", async function () {
      await cap.connect(owner).proposeTaxChange(200, 300, 50);

      expect(await cap.taxChangeTimestamp()).to.not.equal(0);
      expect(await cap.pendingTransferTaxBp()).to.equal(200);

      // Cancel the pending change
      const tx = await cap.connect(owner).cancelTaxChange();
      await expect(tx).to.emit(cap, "TaxChangeCancelled").withArgs(200, 300, 50);

      // Verify state is reset
      expect(await cap.taxChangeTimestamp()).to.equal(0);
      expect(await cap.pendingTransferTaxBp()).to.equal(0);
      expect(await cap.pendingSellTaxBp()).to.equal(0);
      expect(await cap.pendingBuyTaxBp()).to.equal(0);

      // Original taxes unchanged
      expect(await cap.transferTaxBp()).to.equal(100);
      expect(await cap.sellTaxBp()).to.equal(100);
      expect(await cap.buyTaxBp()).to.equal(0);
    });

    it("Should revert when cancelling with no pending change", async function () {
      await expect(cap.connect(owner).cancelTaxChange()).to.be.revertedWith("NO_PENDING_CHANGE");
    });

    it("Should allow cancel even after timelock expired", async function () {
      await cap.connect(owner).proposeTaxChange(200, 300, 50);

      // Wait past timelock
      await time.increase(TIMELOCK_DELAY + 3600);

      // Should still be able to cancel (governance decided not to apply)
      await cap.connect(owner).cancelTaxChange();

      expect(await cap.taxChangeTimestamp()).to.equal(0);
    });

    it("Should allow new proposal after cancellation", async function () {
      // First proposal
      await cap.connect(owner).proposeTaxChange(200, 300, 50);

      // Cancel it
      await cap.connect(owner).cancelTaxChange();

      // Should be able to propose new values
      await cap.connect(owner).proposeTaxChange(150, 250, 25);

      expect(await cap.pendingTransferTaxBp()).to.equal(150);
      expect(await cap.pendingSellTaxBp()).to.equal(250);
      expect(await cap.pendingBuyTaxBp()).to.equal(25);

      // And apply the new proposal
      await time.increase(TIMELOCK_DELAY + 1);
      await cap.connect(owner).applyTaxChange();

      expect(await cap.transferTaxBp()).to.equal(150);
    });

    it("Should prevent non-owner from cancelling", async function () {
      await cap.connect(owner).proposeTaxChange(200, 300, 50);

      await expect(cap.connect(user1).cancelTaxChange()).to.be.revertedWith("ONLY_GOVERNANCE");
    });

    it("Should handle cancel-propose-cancel cycle", async function () {
      // First proposal
      await cap.connect(owner).proposeTaxChange(200, 300, 50);
      await cap.connect(owner).cancelTaxChange();

      // Second proposal
      await cap.connect(owner).proposeTaxChange(150, 250, 25);
      await cap.connect(owner).cancelTaxChange();

      // Third proposal - should work fine
      await cap.connect(owner).proposeTaxChange(100, 200, 0);

      expect(await cap.pendingTransferTaxBp()).to.equal(100);
      expect(await cap.pendingSellTaxBp()).to.equal(200);
    });
  });

  describe("Timelock Interaction with Other Operations", function () {
    it("Should allow overwriting pending proposal with new proposal", async function () {
      // Propose first timelock change
      await cap.connect(owner).proposeTaxChange(200, 300, 50);

      // Owner can propose another change (overwrites first)
      await cap.connect(owner).proposeTaxChange(150, 250, 25);

      expect(await cap.pendingTransferTaxBp()).to.equal(150);

      // Can apply the new (overwritten) proposal after timelock
      await time.increase(TIMELOCK_DELAY + 1);
      await cap.connect(owner).applyTaxChange();

      expect(await cap.transferTaxBp()).to.equal(150);
    });

    it("Should handle transfers during pending timelock", async function () {
      await cap.connect(owner).transfer(user1.address, ethers.utils.parseEther("10000"));

      // Propose tax change
      await cap.connect(owner).proposeTaxChange(400, 400, 200);

      // Transfers should use current tax rates
      const transferAmount = ethers.utils.parseEther("1000");
      const currentTax = transferAmount.mul(100).div(10000); // 1%

      const treasuryBefore = await cap.balanceOf(treasury.address);
      await cap.connect(user1).transfer(owner.address, transferAmount);
      const treasuryAfter = await cap.balanceOf(treasury.address);

      expect(treasuryAfter.sub(treasuryBefore)).to.equal(currentTax);

      // Apply new taxes
      await time.increase(TIMELOCK_DELAY + 1);
      await cap.connect(owner).applyTaxChange();

      // New transfers should use new tax rates
      const newTax = transferAmount.mul(400).div(10000); // 4%

      const treasuryBefore2 = await cap.balanceOf(treasury.address);
      await cap.connect(user1).transfer(owner.address, transferAmount);
      const treasuryAfter2 = await cap.balanceOf(treasury.address);

      expect(treasuryAfter2.sub(treasuryBefore2)).to.equal(newTax);
    });

    it("Should preserve pending proposal through governance transfer", async function () {
      await cap.connect(owner).proposeTaxChange(200, 300, 50);

      const timestampBefore = await cap.taxChangeTimestamp();

      // Transfer governance to user1
      await cap.connect(owner).setGovernance(user1.address);

      // Pending proposal should still exist
      expect(await cap.taxChangeTimestamp()).to.equal(timestampBefore);
      expect(await cap.pendingTransferTaxBp()).to.equal(200);

      // New governance can apply it
      await time.increase(TIMELOCK_DELAY + 1);
      await cap.connect(user1).applyTaxChange();

      expect(await cap.transferTaxBp()).to.equal(200);
    });
  });

  describe("Gas Efficiency During Timelock", function () {
    it("Should measure gas cost of proposing tax change", async function () {
      const tx = await cap.connect(owner).proposeTaxChange(200, 300, 50);
      const receipt = await tx.wait();

      // Should be relatively cheap (mostly storage writes)
      expect(receipt?.gasUsed).to.be.lt(150000);
    });

    it("Should measure gas cost of applying tax change", async function () {
      await cap.connect(owner).proposeTaxChange(200, 300, 50);
      await time.increase(TIMELOCK_DELAY + 1);

      const tx = await cap.connect(owner).applyTaxChange();
      const receipt = await tx.wait();

      // Should be relatively cheap
      expect(receipt?.gasUsed).to.be.lt(100000);
    });

    it("Should measure gas cost of propose + apply cycle", async function () {
      // Timelock change (propose + apply)
      const tx1 = await cap.connect(owner).proposeTaxChange(200, 300, 50);
      const receipt1 = await tx1.wait();
      await time.increase(TIMELOCK_DELAY + 1);
      const tx2 = await cap.connect(owner).applyTaxChange();
      const receipt2 = await tx2.wait();

      const gas1 = receipt1?.gasUsed || ethers.BigNumber.from(0);
      const gas2 = receipt2?.gasUsed || ethers.BigNumber.from(0);
      const totalGas = gas1.add(gas2);

      // Two-step process should be reasonable
      expect(totalGas).to.be.lt(250000);
    });
  });
});

Neighbours