Files
self/contracts/test/unit/ImplRoot.test.ts
Evi Nova bc4e52bb1e Refactor/multitiered multisig roles (#1483)
* refactor: switch to multitiered governance with multisigs

* feat: add scripts for assisting with upgrading contracts and

* test: add tests for governance upgrade

* chore: install Foundry with Hardhat compatability

* fix: add separate intializeGovernance function for upgrading

Uses reinitializer modifier for proper security around function call

* feat: migrate new function to AccessControl governance

* test: full end to end upgrade typescript test

* chore: add hardhat-upgrade

* chore: add foundry outputs to gitignore

* test: add Foundry upgrade script and test for deployed contracts

* refactor: update PCR0 inputs to be 32 bytes for GCP image hashes

Still pad to 48 bytes to ensure compatibility with mobile app.

* feat: add PCR0 migration script + test file

* fix: use custom natspec to prevent constructor warnings on upgrade

* test: cleanup tests and add role transfer to upgrade script

* test: add deployed libraries to foundry.toml for proper library linking

* chore: add /contracts/broadcast to gitignore for foundry deployments

* fix: set variable in initializer instead of defining in declaration

* test: improve upgrade test script to check all state variables

* docs: better explain safety behind using unsafeSkipStorageCheck

* doc: add guide for upgrading to AccessControl governance

* style: change multisig role names

CRITICAL_ROLE -> SECURITY_ROLE (3/5)
STANDARD_ROLE -> OPERATIONRS_ROLE (2/5)

* refactor: change OFAC + CSCA root update functions to 2/5 multisig

* fix: package version clashes + outdated code from old ver of packages

OpenZeppelin v5.5.0 no longer requires __UUPS_Upgradeable_Init, new OZ version requires opcodes that need cancun evmVersion, hard defining @noble/hashes led to clashes with other dependencies

* fix: fix PCR0 tests broken from change in byte size

* feat: add contract upgrade tooling with Safe multisig integration

- Add unified 'upgrade' Hardhat task with automatic safety checks
- Add deployment registry for version tracking
- Add Safe SDK integration for auto-proposing upgrades
- Update UPGRADE_GUIDE.md with new workflow documentation
- Validate version increments, reinitializer, and storage layout

* fix: revert fix on Hub V1 contract that is not supported

* style: update upgraded contracts to not use custom:version-history

* fix: V1 test requires old style as well

* fix: correct registry currentVersion to reflect actual deployed versions

On-chain verification confirmed all contracts are using OLD Ownable2StepUpgradeable:
- Hub: 2.11.0 (was incorrectly 2.12.0)
- Registry: 1.1.0 (was incorrectly 1.2.0)
- IdCard: 1.1.0 (was incorrectly 1.2.0)
- Aadhaar: 1.1.0 (was incorrectly 1.2.0)

Owner address: 0xcaee7aaf115f04d836e2d362a7c07f04db436bd0

* fix: upgrade script now correctly handles pre-defined versions in registry

When upgrading to a version that already exists in registry.json (like 2.12.0),
the script now uses that version's initializerVersion instead of incrementing
from the latest version. This fixes the reinitializer validation for the
governance upgrade.

* fix: upgrade script handles Ownable contracts and outputs transaction data

- Detect Ownable pattern before creating Safe proposals
- Output transaction data for owner direct execution in --prepare-only mode
- Use initializerFunction from registry (initializeGovernance) instead of constructing names
- Skip Safe proposal creation for initial Ownable → AccessControl upgrade
- After upgrade, owner grants SECURITY_ROLE to Safe for future upgrades

* feat: IdentityVerificationHub v2.12.0 deployed on Celo

- Implementation: 0x05FB9D7830889cc389E88198f6A224eA87F01151
- Changelog: Governance upgrade

* feat: IdentityRegistryIdCard v1.2.0 deployed on Celo

- Implementation: 0x7d5e4b7D4c3029aF134D50642674Af8F875118a4
- Changelog: Governance upgrade

* feat: IdentityRegistryAadhaar v1.2.0 deployed on Celo

- Implementation: 0xbD861A9cecf7B0A9631029d55A8CE1155e50697c
- Changelog: Governance upgrade

* feat: IdentityRegistry v1.2.0 deployed on Celo

- Implementation: 0x81E7F74560FAF7eE8DE3a36A5a68B6cbc429Cd36
- Changelog: Governance upgrade

* feat: add multisig addresses to registry

* feat: PCR0Manager v1.2.0 deployed on Celo

- Implementation: 0x9743fe2C1c3D2b068c56dE314e9B10DA9c904717
- Changelog: Governance upgrade

* refactor: cleanup old scripts

* chore: yarn prettier formatting
2025-12-10 17:30:50 +10:00

273 lines
12 KiB
TypeScript

import { expect } from "chai";
import { ethers } from "hardhat";
import { MockImplRoot } from "../../typechain-types";
import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers";
describe("ImplRoot", () => {
let mockImplRoot: MockImplRoot;
let deployer: SignerWithAddress;
let securityMultisig: SignerWithAddress;
let operationsMultisig: SignerWithAddress;
let user1: SignerWithAddress;
const SECURITY_ROLE = ethers.keccak256(ethers.toUtf8Bytes("SECURITY_ROLE"));
const OPERATIONS_ROLE = ethers.keccak256(ethers.toUtf8Bytes("OPERATIONS_ROLE"));
beforeEach(async () => {
[deployer, securityMultisig, operationsMultisig, user1] = await ethers.getSigners();
const MockImplRootFactory = await ethers.getContractFactory("MockImplRoot", deployer);
mockImplRoot = await MockImplRootFactory.deploy();
await mockImplRoot.waitForDeployment();
});
describe("Role Constants", () => {
it("should have correct role constants", async () => {
expect(await mockImplRoot.SECURITY_ROLE()).to.equal(SECURITY_ROLE);
expect(await mockImplRoot.OPERATIONS_ROLE()).to.equal(OPERATIONS_ROLE);
});
});
describe("Initialization", () => {
it("should revert when calling __ImplRoot_init outside initialization phase", async () => {
// First initialize the contract properly
await mockImplRoot.exposed__ImplRoot_init();
// Then try to initialize again - this should fail
await expect(mockImplRoot.exposed__ImplRoot_init()).to.be.revertedWithCustomError(
mockImplRoot,
"InvalidInitialization",
);
});
it("should initialize with deployer having both roles", async () => {
// Deploy a fresh contract for initialization testing
const MockImplRootFactory = await ethers.getContractFactory("MockImplRoot");
const freshContract = await MockImplRootFactory.deploy();
await freshContract.waitForDeployment();
await freshContract.exposed__ImplRoot_init();
// Check role assignments - deployer should have both roles
expect(await freshContract.hasRole(SECURITY_ROLE, deployer.address)).to.be.true;
expect(await freshContract.hasRole(OPERATIONS_ROLE, deployer.address)).to.be.true;
// Check role admins - SECURITY_ROLE manages all roles
expect(await freshContract.getRoleAdmin(SECURITY_ROLE)).to.equal(SECURITY_ROLE);
expect(await freshContract.getRoleAdmin(OPERATIONS_ROLE)).to.equal(SECURITY_ROLE);
});
it("should allow role transfer after initialization", async () => {
// Deploy a fresh contract for initialization testing
const MockImplRootFactory = await ethers.getContractFactory("MockImplRoot");
const freshContract = await MockImplRootFactory.deploy();
await freshContract.waitForDeployment();
await freshContract.exposed__ImplRoot_init();
// Transfer roles to multisigs
await freshContract.connect(deployer).grantRole(SECURITY_ROLE, securityMultisig.address);
await freshContract.connect(deployer).grantRole(OPERATIONS_ROLE, operationsMultisig.address);
// Verify multisigs have roles
expect(await freshContract.hasRole(SECURITY_ROLE, securityMultisig.address)).to.be.true;
expect(await freshContract.hasRole(OPERATIONS_ROLE, operationsMultisig.address)).to.be.true;
// Deployer can renounce roles
await freshContract.connect(deployer).renounceRole(SECURITY_ROLE, deployer.address);
await freshContract.connect(deployer).renounceRole(OPERATIONS_ROLE, deployer.address);
// Verify deployer no longer has roles
expect(await freshContract.hasRole(SECURITY_ROLE, deployer.address)).to.be.false;
expect(await freshContract.hasRole(OPERATIONS_ROLE, deployer.address)).to.be.false;
});
});
describe("Role Management", () => {
let initializedContract: MockImplRoot;
beforeEach(async () => {
const MockImplRootFactory = await ethers.getContractFactory("MockImplRoot");
initializedContract = await MockImplRootFactory.deploy();
await initializedContract.waitForDeployment();
// Initialize with deployer having roles, then transfer to multisigs
await initializedContract.exposed__ImplRoot_init();
await initializedContract.connect(deployer).grantRole(SECURITY_ROLE, securityMultisig.address);
await initializedContract.connect(deployer).grantRole(OPERATIONS_ROLE, operationsMultisig.address);
});
it("should allow critical multisig to grant roles", async () => {
await expect(initializedContract.connect(securityMultisig).grantRole(OPERATIONS_ROLE, user1.address)).to.not.be
.reverted;
expect(await initializedContract.hasRole(OPERATIONS_ROLE, user1.address)).to.be.true;
});
it("should allow critical multisig to revoke roles", async () => {
// First grant a role to user1
await initializedContract.connect(securityMultisig).grantRole(OPERATIONS_ROLE, user1.address);
expect(await initializedContract.hasRole(OPERATIONS_ROLE, user1.address)).to.be.true;
// Then revoke it
await expect(initializedContract.connect(securityMultisig).revokeRole(OPERATIONS_ROLE, user1.address)).to.not.be
.reverted;
expect(await initializedContract.hasRole(OPERATIONS_ROLE, user1.address)).to.be.false;
});
it("should prevent standard multisig from granting critical role", async () => {
await expect(
initializedContract.connect(operationsMultisig).grantRole(SECURITY_ROLE, user1.address),
).to.be.revertedWithCustomError(initializedContract, "AccessControlUnauthorizedAccount");
});
it("should prevent unauthorized users from granting roles", async () => {
await expect(
initializedContract.connect(user1).grantRole(OPERATIONS_ROLE, user1.address),
).to.be.revertedWithCustomError(initializedContract, "AccessControlUnauthorizedAccount");
});
it("should allow role holders to renounce their own roles", async () => {
// Grant role to user1
await initializedContract.connect(securityMultisig).grantRole(OPERATIONS_ROLE, user1.address);
expect(await initializedContract.hasRole(OPERATIONS_ROLE, user1.address)).to.be.true;
// User1 can renounce their own role
await expect(initializedContract.connect(user1).renounceRole(OPERATIONS_ROLE, user1.address)).to.not.be.reverted;
expect(await initializedContract.hasRole(OPERATIONS_ROLE, user1.address)).to.be.false;
});
it("should prevent users from renouncing others' roles", async () => {
await expect(
initializedContract.connect(user1).renounceRole(SECURITY_ROLE, securityMultisig.address),
).to.be.revertedWithCustomError(initializedContract, "AccessControlBadConfirmation");
});
});
describe("Upgrade Authorization", () => {
let initializedContract: MockImplRoot;
beforeEach(async () => {
const MockImplRootFactory = await ethers.getContractFactory("MockImplRoot");
initializedContract = await MockImplRootFactory.deploy();
await initializedContract.waitForDeployment();
// Initialize and transfer roles
await initializedContract.exposed__ImplRoot_init();
await initializedContract.connect(deployer).grantRole(SECURITY_ROLE, securityMultisig.address);
await initializedContract.connect(deployer).grantRole(OPERATIONS_ROLE, operationsMultisig.address);
});
it("should allow critical multisig to authorize upgrades", async () => {
const newImplementation = ethers.Wallet.createRandom().address;
// Note: _authorizeUpgrade is internal and can only be called through proxy upgrade mechanism
// We test this by verifying the critical multisig has the required role
expect(await initializedContract.hasRole(SECURITY_ROLE, securityMultisig.address)).to.be.true;
});
it("should prevent standard multisig from authorizing upgrades", async () => {
const newImplementation = ethers.Wallet.createRandom().address;
// Standard multisig should not have SECURITY_ROLE
expect(await initializedContract.hasRole(SECURITY_ROLE, operationsMultisig.address)).to.be.false;
});
it("should prevent unauthorized users from authorizing upgrades", async () => {
const newImplementation = ethers.Wallet.createRandom().address;
// Unauthorized users should not have SECURITY_ROLE
expect(await initializedContract.hasRole(SECURITY_ROLE, user1.address)).to.be.false;
});
});
describe("Role Hierarchy", () => {
let initializedContract: MockImplRoot;
beforeEach(async () => {
const MockImplRootFactory = await ethers.getContractFactory("MockImplRoot");
initializedContract = await MockImplRootFactory.deploy();
await initializedContract.waitForDeployment();
await initializedContract.exposed__ImplRoot_init();
});
it("should have SECURITY_ROLE as admin of both roles", async () => {
expect(await initializedContract.getRoleAdmin(SECURITY_ROLE)).to.equal(SECURITY_ROLE);
expect(await initializedContract.getRoleAdmin(OPERATIONS_ROLE)).to.equal(SECURITY_ROLE);
});
it("should allow SECURITY_ROLE holders to manage OPERATIONS_ROLE", async () => {
// Grant SECURITY_ROLE to securityMultisig
await initializedContract.connect(deployer).grantRole(SECURITY_ROLE, securityMultisig.address);
// Critical multisig should be able to grant OPERATIONS_ROLE
await expect(initializedContract.connect(securityMultisig).grantRole(OPERATIONS_ROLE, user1.address)).to.not.be
.reverted;
expect(await initializedContract.hasRole(OPERATIONS_ROLE, user1.address)).to.be.true;
// Critical multisig should be able to revoke OPERATIONS_ROLE
await expect(initializedContract.connect(securityMultisig).revokeRole(OPERATIONS_ROLE, user1.address)).to.not.be
.reverted;
expect(await initializedContract.hasRole(OPERATIONS_ROLE, user1.address)).to.be.false;
});
});
describe("Complete Workflow", () => {
it("should demonstrate complete deployment and role transfer workflow", async () => {
console.log("\n🔄 Starting Complete ImplRoot Workflow");
// 1. Deploy contract
const MockImplRootFactory = await ethers.getContractFactory("MockImplRoot");
const contract = await MockImplRootFactory.deploy();
await contract.waitForDeployment();
console.log("✅ Step 1: Contract deployed");
// 2. Initialize with deployer having roles
await contract.exposed__ImplRoot_init();
console.log("✅ Step 2: Contract initialized");
console.log(` - Deployer has SECURITY_ROLE: ${await contract.hasRole(SECURITY_ROLE, deployer.address)}`);
console.log(` - Deployer has OPERATIONS_ROLE: ${await contract.hasRole(OPERATIONS_ROLE, deployer.address)}`);
// 3. Grant roles to multisigs
await contract.connect(deployer).grantRole(SECURITY_ROLE, securityMultisig.address);
await contract.connect(deployer).grantRole(OPERATIONS_ROLE, operationsMultisig.address);
console.log("✅ Step 3: Roles granted to multisigs");
console.log(
` - Critical multisig has SECURITY_ROLE: ${await contract.hasRole(SECURITY_ROLE, securityMultisig.address)}`,
);
console.log(
` - Standard multisig has OPERATIONS_ROLE: ${await contract.hasRole(OPERATIONS_ROLE, operationsMultisig.address)}`,
);
// 4. Verify multisigs can operate (check role permissions)
expect(await contract.hasRole(SECURITY_ROLE, securityMultisig.address)).to.be.true;
console.log("✅ Step 4: Multisigs verified functional");
// 5. Renounce deployer roles
await contract.connect(deployer).renounceRole(SECURITY_ROLE, deployer.address);
await contract.connect(deployer).renounceRole(OPERATIONS_ROLE, deployer.address);
console.log("✅ Step 5: Deployer roles renounced");
console.log(` - Deployer has SECURITY_ROLE: ${await contract.hasRole(SECURITY_ROLE, deployer.address)}`);
console.log(` - Deployer has OPERATIONS_ROLE: ${await contract.hasRole(OPERATIONS_ROLE, deployer.address)}`);
// 6. Final verification
expect(await contract.hasRole(SECURITY_ROLE, securityMultisig.address)).to.be.true;
expect(await contract.hasRole(OPERATIONS_ROLE, operationsMultisig.address)).to.be.true;
expect(await contract.hasRole(SECURITY_ROLE, deployer.address)).to.be.false;
expect(await contract.hasRole(OPERATIONS_ROLE, deployer.address)).to.be.false;
console.log("🎉 Complete ImplRoot workflow successful!");
});
});
});