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);
});
});
});