cyberia-token/test/integration/ZodiacSafe.test.ts

import { expect } from "chai";
import { ethers, upgrades } from "hardhat";
import { CAPToken } from "../../typechain-types";
import { Signer } from "ethers";
import zodiacConfig from "../../docs/zodiac-roles-config.json";

describe("Zodiac Safe Integration Tests", function () {
  let cap: CAPToken;
  let owner: Signer;
  let treasury: Signer;
  let daoAddress: Signer;
  let boardMember1: Signer;
  let user: Signer;

  // Simulated Safe address (in real scenario, this would be actual Safe contract)
  let safeAddress: string;

  beforeEach(async function () {
    [owner, treasury, daoAddress, boardMember1, , , user] = 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;

    // Simulate Safe address (in production, this would be an actual Gnosis Safe)
    safeAddress = treasury.address;
  });

  describe("Zodiac Configuration Validation", function () {
    it("Should validate all function selectors in Zodiac config", async function () {
      console.log("\n๐Ÿ” Validating Zodiac Roles Configuration\n");

      const iface = cap.interface;
      const roles = zodiacConfig.roles;

      let allValid = true;
      const validationResults: Array<{ role: string; function: string; selector: string; valid: boolean }> = [];

      // Deprecated functions that were removed during security fixes
      const deprecatedFunctions = ["setTaxesImmediate", "mint"];

      for (const role of roles) {
        console.log(`๐Ÿ“‹ Role: ${role.name}`);

        for (const target of role.targets) {
          if (target.address === "{{CAP_TOKEN_ADDRESS}}") {
            for (const func of target.functions || []) {
              const functionName = func.name;
              const expectedSelector = func.sighash;

              // Skip deprecated functions
              const baseName = functionName.split("(")[0];
              if (deprecatedFunctions.includes(baseName)) {
                console.log(`  โš ๏ธ  ${functionName}: Deprecated (removed for security)`);
                validationResults.push({
                  role: role.key,
                  function: functionName,
                  selector: expectedSelector,
                  valid: true, // Mark as valid since it's intentionally removed
                });
                continue;
              }

              try {
                // Parse function signature to get just the name and params
                const functionSignature = functionName.includes("(") ? functionName : `${functionName}()`;

                let actualSelector: string;
                try {
                  // Verify function exists in the contract interface (by attempting to get it)
                  iface.getFunction(functionSignature)!;
                  // Compute selector from keccak256 hash of signature
                  const hash = ethers.utils.keccak256(ethers.utils.toUtf8Bytes(functionSignature));
                  actualSelector = hash.slice(0, 10); // Take first 4 bytes (10 chars in hex)
                } catch {
                  // Try without parameters if it fails
                  try {
                    iface.getFunction(baseName)!;
                    const hash = ethers.utils.keccak256(ethers.utils.toUtf8Bytes(`${baseName}()`));
                    actualSelector = hash.slice(0, 10);
                  } catch {
                    throw new Error(`Function ${functionName} not found`);
                  }
                }

                const isValid = actualSelector.toLowerCase() === expectedSelector.toLowerCase();
                validationResults.push({
                  role: role.key,
                  function: functionName,
                  selector: expectedSelector,
                  valid: isValid,
                });

                if (isValid) {
                  console.log(`  โœ… ${functionName}: ${expectedSelector}`);
                } else {
                  console.log(`  โŒ ${functionName}: Expected ${expectedSelector}, Got ${actualSelector}`);
                  allValid = false;
                }
              } catch {
                console.log(`  โš ๏ธ  ${functionName}: Function not found in contract`);
                validationResults.push({
                  role: role.key,
                  function: functionName,
                  selector: expectedSelector,
                  valid: false,
                });
                allValid = false;
              }
            }
          }
        }
        console.log();
      }

      // Summary
      const totalFunctions = validationResults.length;
      const validFunctions = validationResults.filter((r) => r.valid).length;

      console.log("๐Ÿ“Š Validation Summary:");
      console.log(`   Total Functions: ${totalFunctions}`);
      console.log(`   Valid: ${validFunctions}`);
      console.log(`   Invalid: ${totalFunctions - validFunctions}`);
      console.log(`   Success Rate: ${((validFunctions / totalFunctions) * 100).toFixed(1)}%\n`);

      expect(allValid, "All function selectors should match").to.be.true;
    });

    it("Should verify all required admin functions exist", async function () {
      const requiredFunctions = [
        "proposeTaxChange",
        "applyTaxChange",
        "cancelTaxChange",
        "setFeeRecipient",
        "addPool",
        "removePool",
        "proposeMint",
        "executeMint",
        "cancelMint",
        "upgradeToAndCall",
        "setGovernance",
      ];

      console.log("\n๐Ÿ” Checking Required Admin Functions\n");

      for (const funcName of requiredFunctions) {
        const hasFunction = cap.interface.getFunction(funcName) !== undefined;
        console.log(`${hasFunction ? "โœ…" : "โŒ"} ${funcName}`);
        expect(hasFunction, `Function ${funcName} should exist`).to.be.true;
      }
    });
  });

  describe("Simulated Zodiac Roles Permissions", function () {
    describe("BOARD_DAILY_OPS Role", function () {
      it("Should allow board to transfer small amounts (<50k CAP)", async function () {
        // Setup: Transfer tokens to Safe
        await cap.connect(owner).transfer(safeAddress, ethers.utils.parseEther("100000"));

        // Simulate board transfer of 30k CAP (under 50k limit)
        const smallAmount = ethers.utils.parseEther("30000");

        // In real scenario, this would go through Safe + Zodiac Roles
        // Here we simulate the Safe executing the transfer
        await cap.connect(treasury).transfer(user.address, smallAmount);

        const userBalance = await cap.balanceOf(user.address);
        // Note: Transfer tax (1%) is applied, so user receives 99% of the amount
        const expectedAmount = smallAmount.mul(9900).div(10000);
        expect(userBalance).to.equal(expectedAmount);
      });

      it("Should document Zodiac spending limits for medium and large transfers", async function () {
        // This test documents the Zodiac spending limits configured in zodiac-roles-config.json
        // In production, these limits are enforced by the Zodiac Roles Modifier contract

        const limits = {
          small: { amount: "< 50k CAP", role: "BOARD_DAILY_OPS", threshold: "2-of-3 board signatures" },
          medium: { amount: "50k - 200k CAP", role: "BOARD_MEDIUM_OPS", threshold: "Higher board threshold" },
          large: { amount: "> 200k CAP", role: "DAO_LARGE_OPS", threshold: "Full DAO governance vote" },
        };

        // Verify limits are documented in config
        const boardDailyOps = zodiacConfig.roles.find((r) => r.key === "BOARD_DAILY_OPS");
        const boardMediumOps = zodiacConfig.roles.find((r) => r.key === "BOARD_MEDIUM_OPS");
        const daoLargeOps = zodiacConfig.roles.find((r) => r.key === "DAO_LARGE_OPS");

        expect(boardDailyOps).to.exist;
        expect(boardMediumOps).to.exist;
        expect(daoLargeOps).to.exist;

        // Log limits for documentation
        console.log("\n๐Ÿ“Š Zodiac Spending Limits:");
        console.log(`   Small: ${limits.small.amount} โ†’ ${limits.small.role} (${limits.small.threshold})`);
        console.log(`   Medium: ${limits.medium.amount} โ†’ ${limits.medium.role} (${limits.medium.threshold})`);
        console.log(`   Large: ${limits.large.amount} โ†’ ${limits.large.role} (${limits.large.threshold})\n`);
      });
    });

    describe("DAO_TOKEN_ADMIN Role", function () {
      beforeEach(async function () {
        // Set DAO as governance
        await cap.connect(owner).setGovernance(daoAddress.address);
      });

      it("Should allow DAO to call proposeTaxChange", async function () {
        await expect(cap.connect(daoAddress).proposeTaxChange(200, 300, 50)).to.emit(cap, "TaxChangeProposed");
      });

      it("Should allow DAO to call cancelTaxChange", async function () {
        // First propose a change
        await cap.connect(daoAddress).proposeTaxChange(200, 300, 50);

        // Then cancel it
        await expect(cap.connect(daoAddress).cancelTaxChange())
          .to.emit(cap, "TaxChangeCancelled")
          .withArgs(200, 300, 50);
      });

      it("Should allow DAO to call setFeeRecipient", async function () {
        const newRecipient = user.address;
        await expect(cap.connect(daoAddress).setFeeRecipient(newRecipient))
          .to.emit(cap, "FeeRecipientUpdated")
          .withArgs(treasury.address, newRecipient);
      });

      it("Should allow DAO to call addPool", async function () {
        const poolAddress = "0x1111111111111111111111111111111111111111";
        await expect(cap.connect(daoAddress).addPool(poolAddress)).to.emit(cap, "PoolAdded").withArgs(poolAddress);
      });

      it("Should allow DAO to call removePool", async function () {
        const poolAddress = "0x1111111111111111111111111111111111111111";
        await cap.connect(daoAddress).addPool(poolAddress);
        await expect(cap.connect(daoAddress).removePool(poolAddress)).to.emit(cap, "PoolRemoved").withArgs(poolAddress);
      });

      it("Should allow DAO to propose and execute mint within max supply", async function () {
        const mintAmount = ethers.utils.parseEther("1000000");
        await cap.connect(daoAddress).proposeMint(user.address, mintAmount);

        // Fast forward 7 days
        await ethers.provider.send("evm_increaseTime", [7 * 24 * 60 * 60]);
        await ethers.provider.send("evm_mine", []);

        await expect(cap.connect(daoAddress).executeMint())
          .to.emit(cap, "TokensMinted")
          .withArgs(user.address, mintAmount);
      });

      it("Should block non-DAO from calling admin functions", async function () {
        await expect(cap.connect(boardMember1).proposeTaxChange(200, 300, 50)).to.be.revertedWith("ONLY_GOVERNANCE");

        await expect(cap.connect(user).addPool("0x1111111111111111111111111111111111111111")).to.be.revertedWith(
          "ONLY_GOVERNANCE"
        );
      });
    });

    describe("Tax Parameter Validation (Zodiac Constraints)", function () {
      beforeEach(async function () {
        await cap.connect(owner).setGovernance(daoAddress.address);
      });

      it("Should enforce individual tax caps (โ‰ค500 bp)", async function () {
        // Valid: at the limit
        await expect(cap.connect(daoAddress).proposeTaxChange(500, 300, 0)).to.not.be.reverted;

        // Invalid: exceeds limit
        await expect(cap.connect(daoAddress).proposeTaxChange(501, 300, 0)).to.be.revertedWith("TRANSFER_TAX_TOO_HIGH");
      });

      it("Should enforce total tax cap (transfer + sell + buy โ‰ค1000 bp)", async function () {
        // Valid: at the limit
        await expect(cap.connect(daoAddress).proposeTaxChange(400, 400, 200)).to.not.be.reverted;

        // Invalid: exceeds total limit
        await expect(cap.connect(daoAddress).proposeTaxChange(400, 400, 300)).to.be.revertedWith("TOTAL_TAX_TOO_HIGH");
      });

      it("Should match Zodiac config constraints (โ‰ค501 bp per config)", async function () {
        // Zodiac config specifies LessThan 501, which means โ‰ค500
        // This should work - propose and apply
        await cap.connect(daoAddress).proposeTaxChange(500, 300, 50);
        await ethers.provider.send("evm_increaseTime", [24 * 60 * 60]);
        await ethers.provider.send("evm_mine", []);
        await expect(cap.connect(daoAddress).applyTaxChange()).to.not.be.reverted;

        // This should fail (above Zodiac limit)
        await expect(cap.connect(daoAddress).proposeTaxChange(501, 300, 50)).to.be.revertedWith(
          "TRANSFER_TAX_TOO_HIGH"
        );
      });
    });
  });

  describe("Complete Governance Workflow Simulation", function () {
    beforeEach(async function () {
      // Give DAO some tokens before transferring governance (500k tokens)
      await cap.connect(owner).transfer(daoAddress.address, ethers.utils.parseEther("500000"));

      // Setup: Give DAO governance control
      await cap.connect(owner).setGovernance(daoAddress.address);

      // DAO gives Safe some tokens (100k)
      await cap.connect(daoAddress).transfer(safeAddress, ethers.utils.parseEther("100000"));
    });

    it("Should simulate complete DAO governance workflow", async function () {
      // Step 1: DAO proposes tax change
      await cap.connect(daoAddress).proposeTaxChange(200, 300, 50);
      const timestamp = await cap.taxChangeTimestamp();
      expect(timestamp).to.be.gt(0);

      // Step 2: DAO cancels proposal (found an error)
      await expect(cap.connect(daoAddress).cancelTaxChange()).to.emit(cap, "TaxChangeCancelled").withArgs(200, 300, 50);

      // Step 3: DAO submits corrected proposal
      await cap.connect(daoAddress).proposeTaxChange(150, 250, 25);

      // Verify final state
      expect(await cap.pendingTransferTaxBp()).to.equal(150);
      expect(await cap.pendingSellTaxBp()).to.equal(250);
      expect(await cap.pendingBuyTaxBp()).to.equal(25);
    });

    it("Should simulate Safe treasury operations with token transfers", async function () {
      const safeBalanceBefore = await cap.balanceOf(safeAddress);
      const smallAmount = ethers.utils.parseEther("30000");

      // Simulate small transfer that board can approve
      await cap.connect(treasury).transfer(user.address, smallAmount);

      // Verify balance change accounting for tax
      // Note: safeAddress = treasury (fee recipient), so it receives its own tax back
      const userReceived = smallAmount.mul(9900).div(10000);
      const expectedBalance = safeBalanceBefore.sub(userReceived);
      expect(await cap.balanceOf(safeAddress)).to.equal(expectedBalance);
    });
  });

  describe("Production Deployment Checklist", function () {
    it("Should validate Zodiac config is production-ready", function () {
      const checks = [
        { name: "Zodiac config version specified", pass: zodiacConfig.version === "1.0" },
        { name: "Chain ID matches Sepolia", pass: zodiacConfig.chainId === "11155111" },
        { name: "BOARD_DAILY_OPS defined", pass: zodiacConfig.roles.some((r) => r.key === "BOARD_DAILY_OPS") },
        { name: "BOARD_MEDIUM_OPS defined", pass: zodiacConfig.roles.some((r) => r.key === "BOARD_MEDIUM_OPS") },
        { name: "DAO_LARGE_OPS defined", pass: zodiacConfig.roles.some((r) => r.key === "DAO_LARGE_OPS") },
        { name: "DAO_TOKEN_ADMIN defined", pass: zodiacConfig.roles.some((r) => r.key === "DAO_TOKEN_ADMIN") },
        {
          name: "Spending limits configured",
          pass: zodiacConfig.roles.some((r) => r.targets.some((t) => t.functions?.some((f) => f.condition))),
        },
      ];

      checks.forEach((check) => expect(check.pass, check.name).to.be.true);
    });
  });
});

Neighbours