Files
self/contracts/tasks/upgrade/upgrade.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

1009 lines
40 KiB
TypeScript

/**
* upgrade task
*
* Combined task that handles the full upgrade workflow:
* 1. Validates and deploys new implementation
* 2. Creates Safe proposal for multisig approval
*
* Smart behavior:
* - If caller IS a multisig signer → auto-creates Safe proposal
* - If caller is NOT a signer → outputs data + URL for manual submission
*
* Usage:
* npx hardhat upgrade --contract DummyContract --network sepolia
* npx hardhat upgrade --contract DummyContract --network sepolia --prepare-only
*/
import { task, types } from "hardhat/config";
import { HardhatRuntimeEnvironment } from "hardhat/types";
import {
log,
getContractDefinition,
getProxyAddress,
getCurrentVersion,
getGitCommitShort,
getGitBranch,
hasUncommittedChanges,
validateVersionIncrement,
suggestNextVersion,
readContractVersion,
getContractFilePath,
addVersion,
getExplorerUrl,
shortenAddress,
createGitTag,
gitCommit,
getLatestVersionInfo,
getVersionInfo,
getGovernanceConfig,
validateReinitializerVersion,
} from "./utils";
import { execSync } from "child_process";
import * as readline from "readline";
import { CONTRACT_IDS, ContractId, SupportedNetwork } from "./types";
/**
* Prompt user for yes/no confirmation
*/
async function promptYesNo(question: string): Promise<boolean> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
rl.question(`${question} (y/n): `, (answer) => {
rl.close();
resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes");
});
});
}
/**
* Network configuration - single source of truth
*/
const CHAIN_CONFIG: Record<SupportedNetwork, { chainId: number; safePrefix: string }> = {
celo: { chainId: 42220, safePrefix: "celo" },
"celo-sepolia": { chainId: 44787, safePrefix: "celo" },
sepolia: { chainId: 11155111, safePrefix: "sep" },
localhost: { chainId: 31337, safePrefix: "eth" },
};
function getChainId(network: SupportedNetwork): number {
return CHAIN_CONFIG[network]?.chainId || 0;
}
function getChainPrefix(network: SupportedNetwork): string {
return CHAIN_CONFIG[network]?.safePrefix || network;
}
/**
* Check if address is a Safe owner and propose transaction if so
* Uses Safe SDK as documented at:
* https://docs.safe.global/core-api/transaction-service-guides/transactions
*/
async function checkOwnerAndPropose(
safeAddress: string,
signerAddress: string,
to: string,
data: string,
network: SupportedNetwork,
hre: HardhatRuntimeEnvironment,
): Promise<{ isOwner: boolean; proposed: boolean; safeTxHash?: string; error?: string }> {
try {
// Dynamic import Safe SDK
const SafeApiKit = require("@safe-global/api-kit").default;
const Safe = require("@safe-global/protocol-kit").default;
const chainId = BigInt(getChainId(network));
if (network === "localhost") {
return { isOwner: false, proposed: false, error: "No Safe service for localhost" };
}
// Get Safe Transaction Service URL for the network
const txServiceUrls: Record<string, string> = {
sepolia: "https://safe-transaction-sepolia.safe.global/api",
celo: "https://safe-transaction-celo.safe.global/api",
"celo-sepolia": "https://safe-transaction-celo.safe.global/api",
};
const txServiceUrl = txServiceUrls[network];
if (!txServiceUrl) {
return { isOwner: false, proposed: false, error: `No Safe Transaction Service URL for ${network}` };
}
// Initialize API Kit with explicit service URL (no API key needed)
const apiKit = new SafeApiKit({ chainId, txServiceUrl });
// Check if signer is owner
let safeInfo;
try {
safeInfo = await apiKit.getSafeInfo(safeAddress);
} catch (e: any) {
// The Safe might not be indexed yet - this is common for new Safes
const errorMsg = e.message || String(e);
if (errorMsg.includes("Not Found") || errorMsg.includes("404")) {
return {
isOwner: false,
proposed: false,
error: `Safe not indexed by Transaction Service yet. This is normal for new Safes - please submit manually.`,
};
}
return { isOwner: false, proposed: false, error: `Could not fetch Safe info: ${errorMsg}` };
}
const isOwner = safeInfo.owners.map((o: string) => o.toLowerCase()).includes(signerAddress.toLowerCase());
if (!isOwner) {
return { isOwner: false, proposed: false };
}
// Signer IS an owner - try to propose
try {
// Get RPC URL and private key from hardhat config
const networkConfig = hre.config.networks[network] as any;
const rpcUrl = networkConfig?.url || `http://127.0.0.1:8545`;
// Extract private key from network config
let privateKey: string | undefined;
if (networkConfig?.accounts) {
if (Array.isArray(networkConfig.accounts) && networkConfig.accounts.length > 0) {
// accounts: [PRIVATE_KEY]
privateKey = networkConfig.accounts[0];
} else if (typeof networkConfig.accounts === "object" && networkConfig.accounts.mnemonic) {
// accounts: { mnemonic: "..." } - can't easily extract, skip auto-propose
return {
isOwner: true,
proposed: false,
error: "Mnemonic accounts not supported for auto-propose. Please submit manually.",
};
}
}
if (!privateKey) {
return {
isOwner: true,
proposed: false,
error: "Could not extract private key from Hardhat config. Please submit manually.",
};
}
// Ensure private key has 0x prefix
if (!privateKey.startsWith("0x")) {
privateKey = `0x${privateKey}`;
}
// Initialize Protocol Kit with private key for signing
const protocolKit = await Safe.init({
provider: rpcUrl,
signer: privateKey, // Use private key, not address!
safeAddress,
});
// Create Safe transaction
const safeTransaction = await protocolKit.createTransaction({
transactions: [{ to, value: "0", data }],
});
// Get transaction hash and sign
const safeTxHash = await protocolKit.getTransactionHash(safeTransaction);
const signature = await protocolKit.signHash(safeTxHash);
// Propose transaction to Safe Transaction Service
await apiKit.proposeTransaction({
safeAddress,
safeTransactionData: safeTransaction.data,
safeTxHash,
senderAddress: signerAddress,
senderSignature: signature.data,
});
return { isOwner: true, proposed: true, safeTxHash };
} catch (e: any) {
return { isOwner: true, proposed: false, error: e.message };
}
} catch (e: any) {
return { isOwner: false, proposed: false, error: e.message };
}
}
interface UpgradeTaskArgs {
contract: ContractId;
changelog?: string;
dryRun: boolean;
prepareOnly: boolean;
skipCommit: boolean;
}
task("upgrade", "Deploy new implementation and create Safe proposal for upgrade")
.addParam("contract", `Contract to upgrade (${CONTRACT_IDS.join(", ")})`, undefined, types.string)
.addOptionalParam("changelog", "Changelog entry for this version", undefined, types.string)
.addFlag("dryRun", "Simulate without deploying or proposing")
.addFlag("prepareOnly", "Only deploy implementation, skip Safe proposal")
.addFlag("skipCommit", "Skip auto-commit after deployment")
.setAction(async (args: UpgradeTaskArgs, hre: HardhatRuntimeEnvironment) => {
const { contract: contractId, changelog, dryRun, prepareOnly, skipCommit } = args;
const network = hre.network.name as SupportedNetwork;
log.header(`UPGRADE: ${contractId}`);
log.detail("Network", network);
log.detail("Mode", dryRun ? "DRY RUN" : prepareOnly ? "PREPARE ONLY" : "FULL UPGRADE");
// ========================================================================
// Step 1: Validate inputs
// ========================================================================
log.step("Validating inputs...");
if (!CONTRACT_IDS.includes(contractId as ContractId)) {
log.error(`Invalid contract: ${contractId}`);
log.info(`Valid contracts: ${CONTRACT_IDS.join(", ")}`);
return;
}
const contractDef = getContractDefinition(contractId);
if (contractDef.type !== "uups-proxy") {
log.error(`Contract '${contractId}' is not upgradeable (type: ${contractDef.type})`);
return;
}
let proxyAddress: string;
try {
proxyAddress = getProxyAddress(contractId, network);
} catch {
log.error(`No proxy deployed for '${contractId}' on network '${network}'`);
log.info("Deploy the proxy first using the deploy script");
return;
}
const currentVersion = getCurrentVersion(contractId, network);
log.detail("Contract source", contractDef.source);
log.detail("Proxy address", proxyAddress);
log.detail("Current version", currentVersion);
// ========================================================================
// Step 2: Determine and validate new version
// ========================================================================
log.step("Validating version...");
const contractFilePath = getContractFilePath(contractId);
const contractFileVersion = contractFilePath ? readContractVersion(contractFilePath) : null;
if (contractFileVersion) {
log.detail("Version in contract file", contractFileVersion);
}
let newVersion: string;
if (contractFileVersion && contractFileVersion !== currentVersion) {
const validation = validateVersionIncrement(currentVersion, contractFileVersion);
if (validation.valid) {
newVersion = contractFileVersion;
log.info(`Using version from contract file: ${newVersion}`);
} else {
log.error(`Contract file has invalid version ${contractFileVersion}`);
const suggestions = suggestNextVersion(currentVersion);
log.info(`Current version: ${currentVersion}`);
log.info(
`Valid next versions: ${suggestions.patch} (patch), ${suggestions.minor} (minor), ${suggestions.major} (major)`,
);
return;
}
} else {
log.error("Contract file version matches current - update @custom:version in contract first");
const suggestions = suggestNextVersion(currentVersion);
log.info(`Current version: ${currentVersion}`);
log.info(
`Valid next versions: ${suggestions.patch} (patch), ${suggestions.minor} (minor), ${suggestions.major} (major)`,
);
return;
}
// Validate version increment
const versionValidation = validateVersionIncrement(currentVersion, newVersion);
if (!versionValidation.valid) {
log.error(versionValidation.error!);
return;
}
log.success(`Version increment valid: ${currentVersion}${newVersion} (${versionValidation.type})`);
// ========================================================================
// Step 3: Validate reinitializer version
// ========================================================================
log.step("Checking reinitializer version...");
// Check if target version already exists in registry
const targetVersionInfo = getVersionInfo(contractId, newVersion);
const latestVersionInfo = getLatestVersionInfo(contractId);
// If target version exists, use its initializerVersion; otherwise increment latest
const expectedInitializerVersion = targetVersionInfo
? targetVersionInfo.initializerVersion
: (latestVersionInfo?.info.initializerVersion || 0) + 1;
if (contractFilePath) {
const reinitValidation = validateReinitializerVersion(contractFilePath, expectedInitializerVersion);
if (!reinitValidation.valid) {
log.error(reinitValidation.error!);
log.box([
"REINITIALIZER VERSION MISMATCH",
"═".repeat(50),
"",
`Expected: reinitializer(${expectedInitializerVersion})`,
reinitValidation.actual !== null ? `Found: reinitializer(${reinitValidation.actual})` : "Found: none",
"",
"The initialize function must use the correct reinitializer version.",
"Each upgrade should increment the version by 1.",
"",
"Example pattern:",
` function initialize(...) external reinitializer(${expectedInitializerVersion}) {`,
" // initialization logic",
" }",
]);
return;
}
log.success(`Reinitializer version correct: reinitializer(${reinitValidation.actual})`);
} else {
log.warning("Could not locate contract file - skipping reinitializer check");
}
// ========================================================================
// Step 4: Check git state
// ========================================================================
log.step("Checking git state...");
const gitBranch = getGitBranch();
const uncommittedChanges = hasUncommittedChanges();
log.detail("Branch", gitBranch);
if (uncommittedChanges && !dryRun) {
log.warning("You have uncommitted changes. They will be included in the auto-commit.");
}
// ========================================================================
// Step 5: Clear cache and compile fresh
// ========================================================================
log.step("Clearing cache and compiling fresh...");
try {
execSync("npx hardhat clean", { cwd: process.cwd(), stdio: "pipe" });
log.info("Cache cleared");
execSync("npx hardhat compile", { cwd: process.cwd(), stdio: "pipe" });
log.info("Contracts compiled");
} catch (e: any) {
log.warning(`Cache/compile issue: ${e.message?.slice(0, 100) || "unknown"}`);
}
// ========================================================================
// Step 6: Load and validate the new implementation
// ========================================================================
log.step("Loading contract factory...");
const contractName = contractDef.source;
let ContractFactory;
try {
if (contractName === "IdentityVerificationHubImplV2") {
const CustomVerifier = await hre.ethers.getContractFactory("CustomVerifier");
const customVerifier = await CustomVerifier.deploy();
await customVerifier.waitForDeployment();
ContractFactory = await hre.ethers.getContractFactory(contractName, {
libraries: { CustomVerifier: await customVerifier.getAddress() },
});
log.info("Deployed CustomVerifier library for linking");
} else if (
contractName === "IdentityRegistryImplV1" ||
contractName === "IdentityRegistryIdCardImplV1" ||
contractName === "IdentityRegistryAadhaarImplV1"
) {
const PoseidonT3 = await hre.ethers.getContractFactory("PoseidonT3");
const poseidonT3 = await PoseidonT3.deploy();
await poseidonT3.waitForDeployment();
ContractFactory = await hre.ethers.getContractFactory(contractName, {
libraries: { PoseidonT3: await poseidonT3.getAddress() },
});
log.info("Deployed PoseidonT3 library for linking");
} else {
ContractFactory = await hre.ethers.getContractFactory(contractName);
}
log.success(`Loaded contract factory: ${contractName}`);
} catch (error) {
log.error(`Failed to load contract factory: ${error}`);
return;
}
// ========================================================================
// Step 7: Validate storage layout
// ========================================================================
log.step("Validating storage layout compatibility...");
// Track if we're using unsafe options (they print warnings)
const usingUnsafeOptions = true; // We use unsafeAllow: ["constructor"]
try {
await hre.upgrades.validateImplementation(ContractFactory, {
kind: "uups",
unsafeAllowLinkedLibraries: true,
unsafeAllow: ["constructor", "external-library-linking"],
});
log.success("Storage layout validation passed");
// If we used unsafe options, prompt user to confirm
if (usingUnsafeOptions && !dryRun) {
log.warning("⚠️ Using unsafeAllow flags (constructor, external-library-linking)");
log.info("This is expected for contracts with _disableInitializers() in constructor.");
const proceed = await promptYesNo("Continue with deployment?");
if (!proceed) {
log.info("Upgrade cancelled by user.");
return;
}
}
} catch (error: any) {
const errorMsg = error.message || String(error);
// Check if it's a critical error vs a warning
const isCritical =
errorMsg.includes("is not upgrade safe") ||
errorMsg.includes("storage layout") ||
errorMsg.includes("deleted") ||
errorMsg.includes("renamed") ||
errorMsg.includes("changed type");
if (isCritical) {
log.error("❌ CRITICAL: Storage layout validation FAILED");
log.box([
"UPGRADE BLOCKED - CRITICAL ISSUE DETECTED",
"═".repeat(50),
"",
"The new contract version has incompatible storage changes.",
"Deploying this would CORRUPT existing contract state.",
"",
"Error details:",
errorMsg.slice(0, 500),
"",
"Common causes:",
"• Removed or renamed storage variables",
"• Changed variable types",
"• Reordered storage variables",
"",
"Fix: Review storage layout and ensure backwards compatibility.",
"See: https://docs.openzeppelin.com/upgrades-plugins/writing-upgradeable",
]);
return;
}
// It's a warning - prompt user
log.warning("⚠️ Storage layout validation has warnings:");
console.log(`\n${errorMsg}\n`);
const proceed = await promptYesNo("Continue despite warnings?");
if (!proceed) {
log.info("Upgrade cancelled by user.");
return;
}
log.warning("Proceeding with warnings - ensure you understand the risks!");
}
// ========================================================================
// Step 8: Dry run summary
// ========================================================================
if (dryRun) {
log.step("DRY RUN - Skipping deployment and proposal");
log.box(
[
"DRY RUN SUMMARY",
"─".repeat(50),
`Contract: ${contractId}`,
`Version: ${currentVersion}${newVersion}`,
`Network: ${network}`,
`Proxy: ${proxyAddress}`,
"",
"What would happen:",
"1. Deploy new implementation contract",
"2. Update registry.json",
"3. Create git commit and tag",
prepareOnly ? "" : "4. Create Safe proposal for multisig approval",
"",
"Run without --dry-run to execute.",
].filter(Boolean),
);
return;
}
// ========================================================================
// Step 9: Check if implementation actually changed
// ========================================================================
log.step("Checking if implementation bytecode changed...");
try {
const currentImplAddress = await hre.upgrades.erc1967.getImplementationAddress(proxyAddress);
const currentBytecode = await hre.ethers.provider.getCode(currentImplAddress);
const newBytecode = ContractFactory.bytecode;
// Compare bytecode (excluding constructor args and metadata)
// We compare the first 80% as metadata hash at end can differ
const compareLength = Math.floor(Math.min(currentBytecode.length, newBytecode.length) * 0.8);
const currentCompare = currentBytecode.slice(0, compareLength);
const newCompare = newBytecode.slice(0, compareLength);
if (currentCompare === newCompare) {
log.warning("⚠️ New implementation bytecode appears identical to current!");
log.info(`Current impl: ${currentImplAddress}`);
const proceed = await promptYesNo("Deploy anyway?");
if (!proceed) {
log.info("Upgrade cancelled - no changes to deploy.");
return;
}
} else {
log.success("Bytecode changed - proceeding with deployment");
}
} catch (e: any) {
log.info(`Could not compare bytecode: ${e.message} - proceeding with deployment`);
}
// ========================================================================
// Step 10: Deploy new implementation
// ========================================================================
log.step("Deploying new implementation...");
let implementationAddress: string;
try {
const implementation = await ContractFactory.deploy();
await implementation.waitForDeployment();
implementationAddress = await implementation.getAddress();
log.success(`Implementation deployed: ${implementationAddress}`);
log.detail("Explorer", `${getExplorerUrl(network)}/address/${implementationAddress}`);
} catch (error) {
log.error(`Deployment failed: ${error}`);
return;
}
// ========================================================================
// Step 11: Verify on block explorer
// ========================================================================
log.step("Verifying contract on block explorer...");
try {
await hre.run("verify:verify", {
address: implementationAddress,
constructorArguments: [],
});
log.success("Contract verified on block explorer");
} catch (error: any) {
if (error.message?.includes("Already Verified")) {
log.info("Contract already verified");
} else {
log.warning(`Verification failed: ${error.message}`);
log.info("You can verify manually later");
}
}
// ========================================================================
// Step 12: Update registry
// ========================================================================
log.step("Updating deployment registry...");
const latestVersion = getLatestVersionInfo(contractId);
const newInitializerVersion = (latestVersion?.info.initializerVersion || 0) + 1;
const deployerAddress = (await hre.ethers.provider.getSigner()).address;
addVersion(
contractId,
network,
newVersion,
{
initializerVersion: newInitializerVersion,
initializerFunction: "initialize", // Always "initialize" - version tracked via reinitializer(N) modifier
changelog: changelog || `Upgrade to v${newVersion}`,
gitTag: `${contractId.toLowerCase()}-v${newVersion}`,
},
{
impl: implementationAddress,
deployedAt: new Date().toISOString(),
deployedBy: deployerAddress,
gitCommit: "",
},
);
log.success("Registry updated");
// ========================================================================
// Step 13: Auto-commit and tag
// ========================================================================
if (!skipCommit) {
log.step("Creating git commit...");
const commitMessage = `feat: ${contractId} v${newVersion} deployed on ${network.charAt(0).toUpperCase() + network.slice(1)}
- Implementation: ${implementationAddress}
- Changelog: ${changelog || "Upgrade"}`;
const committed = gitCommit(commitMessage);
if (committed) {
const newGitCommit = getGitCommitShort();
log.success(`Committed: ${newGitCommit}`);
const tagName = `${contractId.toLowerCase()}-v${newVersion}`;
try {
// Delete existing tag if it exists (safe - we're creating a new commit anyway)
try {
execSync(`git tag -d ${tagName}`, { cwd: process.cwd(), stdio: "pipe" });
log.info(`Deleted existing tag: ${tagName}`);
} catch {
// Tag didn't exist, that's fine
}
createGitTag(tagName, `${contractId} v${newVersion} - ${changelog || "Upgrade"}`);
log.success(`Created git tag: ${tagName}`);
} catch (e: any) {
log.warning(`Could not create git tag: ${e.message}`);
}
} else {
log.warning("Could not create git commit - please commit manually");
}
}
// ========================================================================
// Step 14: Encode initializer and detect governance pattern
// ========================================================================
log.step("Encoding upgrade transaction...");
const proxyContract = await hre.ethers.getContractAt(contractName, proxyAddress);
// Encode initializer function call
let initData = "0x";
const targetVersionInfoForInit = getVersionInfo(contractId, newVersion);
const initializerName = targetVersionInfoForInit?.initializerFunction || `initializeV${newInitializerVersion}`;
try {
const iface = proxyContract.interface;
const initFragment = iface.getFunction(initializerName);
if (initFragment && initFragment.inputs.length === 0) {
initData = iface.encodeFunctionData(initializerName, []);
log.detail("Initializer", initializerName);
}
} catch {
log.detail("Initializer", "None");
}
// Build upgrade transaction data
const upgradeData = proxyContract.interface.encodeFunctionData("upgradeToAndCall", [
implementationAddress,
initData,
]);
// Detect governance pattern
log.step("Detecting contract governance pattern...");
let isOwnableContract = false;
let currentOwner: string | null = null;
// Check if contract uses Ownable or AccessControl
try {
const ownerFragment = proxyContract.interface.getFunction("owner");
if (ownerFragment) {
isOwnableContract = true;
currentOwner = await proxyContract.owner();
log.info(`Contract uses Ownable pattern - current owner: ${currentOwner}`);
} else {
log.info("Contract uses AccessControl pattern");
}
} catch (e: any) {
log.warning(`Could not detect governance pattern: ${e.message}`);
}
// ========================================================================
// Step 15: Handle --prepare-only mode or create Safe proposal
// ========================================================================
if (prepareOnly) {
if (isOwnableContract && currentOwner) {
log.box([
"IMPLEMENTATION DEPLOYED - OWNER EXECUTION REQUIRED",
"═".repeat(70),
`Contract: ${contractId}`,
`Version: ${currentVersion}${newVersion}`,
`Network: ${network}`,
"",
"Addresses:",
` Proxy: ${shortenAddress(proxyAddress)}`,
` New Impl: ${shortenAddress(implementationAddress)}`,
` Current Owner: ${currentOwner}`,
"",
"⚠️ FIRST GOVERNANCE UPGRADE DETECTED",
"",
"This contract uses Ownable - the current owner must execute",
"the upgrade directly. After upgrade, grant SECURITY_ROLE to Safe.",
"",
"Transaction Data for Owner Execution:",
` To: ${proxyAddress}`,
` Data: ${upgradeData}`,
"",
"After upgrade completes:",
" 1. Grant SECURITY_ROLE to security multisig",
" 2. Grant OPERATIONS_ROLE to operations multisig",
" 3. Run: npx hardhat run scripts/transferRolesToMultisigs.ts --network " + network,
]);
} else {
log.box([
"IMPLEMENTATION DEPLOYED",
"═".repeat(50),
`Contract: ${contractId}`,
`Version: ${currentVersion}${newVersion}`,
`Network: ${network}`,
"",
"Addresses:",
` Proxy: ${shortenAddress(proxyAddress)}`,
` New Impl: ${shortenAddress(implementationAddress)}`,
"",
"Next steps:",
" Run without --prepare-only to create Safe proposal",
" Or manually propose via Safe UI",
]);
}
return;
}
log.step("Creating Safe proposal...");
const governance = getGovernanceConfig(network);
if (!governance.securityMultisig) {
log.error(`No security multisig configured for network '${network}'`);
log.info("Update deployments/registry.json with governance addresses");
log.info("Implementation deployed - propose manually via Safe UI");
return;
}
log.detail("Security multisig", governance.securityMultisig);
log.detail("Required threshold", governance.securityThreshold);
// Check if Safe has SECURITY_ROLE on the proxy
log.step("Verifying Safe has SECURITY_ROLE...");
const SECURITY_ROLE = hre.ethers.keccak256(hre.ethers.toUtf8Bytes("SECURITY_ROLE"));
let safeHasRole = false;
if (isOwnableContract) {
// Contract uses Ownable - owner must execute directly
log.warning("⚠️ FIRST GOVERNANCE UPGRADE DETECTED");
log.box([
"OWNER DIRECT EXECUTION REQUIRED",
"═".repeat(70),
"",
"This is the first upgrade from Ownable to AccessControl.",
"The Safe cannot execute this upgrade because it doesn't have",
"the owner role yet.",
"",
`Current owner: ${currentOwner}`,
`Proxy address: ${proxyAddress}`,
"",
"Transaction Data for Owner Execution:",
` To: ${proxyAddress}`,
` Data: ${upgradeData}`,
"",
"After upgrade completes:",
" 1. Grant SECURITY_ROLE to security multisig",
" 2. Grant OPERATIONS_ROLE to operations multisig",
" 3. Run: npx hardhat run scripts/transferRolesToMultisigs.ts --network " + network,
"",
"Future upgrades can use Safe proposals.",
]);
return;
}
// Contract uses AccessControl - check if Safe has SECURITY_ROLE
try {
safeHasRole = await proxyContract.hasRole(SECURITY_ROLE, governance.securityMultisig);
if (!safeHasRole) {
log.error("❌ SECURITY_ROLE CHECK FAILED");
log.box([
"UPGRADE BLOCKED - SAFE MISSING REQUIRED ROLE",
"═".repeat(50),
"",
"The Safe multisig does NOT have SECURITY_ROLE on this contract.",
"The upgrade transaction will FAIL if submitted.",
"",
`Safe address: ${governance.securityMultisig}`,
`Proxy address: ${proxyAddress}`,
"",
"To fix, grant SECURITY_ROLE to the Safe:",
"",
" 1. From the current admin account, call:",
` contract.grantRole(SECURITY_ROLE, "${governance.securityMultisig}")`,
"",
" 2. Or run this script:",
" npx hardhat run scripts/grantRoleToSafe.ts --network " + network,
"",
"After granting the role, re-run this upgrade command.",
]);
return;
}
log.success("Safe has SECURITY_ROLE ✓");
} catch (e: any) {
log.warning(`Could not verify SECURITY_ROLE: ${e.message}`);
log.info("Proceeding anyway - ensure Safe has the role before executing");
}
// ========================================================================
// Step 15: Handle Safe proposal or owner direct execution
// ========================================================================
// Skip Safe proposal if this is an Ownable contract (first upgrade)
if (isOwnableContract) {
log.step("Skipping Safe proposal - owner must execute directly");
log.box([
"OWNER DIRECT EXECUTION REQUIRED",
"═".repeat(70),
"",
"Transaction Data:",
` To: ${proxyAddress}`,
` Data: ${upgradeData}`,
"",
"Execute this transaction from the owner account:",
` ${currentOwner}`,
"",
"After upgrade completes, grant SECURITY_ROLE to Safe:",
` ${governance.securityMultisig || "Not configured"}`,
"",
"Future upgrades can use Safe proposals.",
]);
return;
}
log.step("Checking if you're a multisig signer...");
const result = await checkOwnerAndPropose(
governance.securityMultisig,
deployerAddress,
proxyAddress,
upgradeData,
network,
hre,
);
if (result.isOwner && result.proposed) {
// Successfully proposed!
log.success("You ARE a signer - transaction auto-proposed!");
const safeUrl = `https://app.safe.global/transactions/queue?safe=${getChainPrefix(network)}:${governance.securityMultisig}`;
console.log("\n" + "═".repeat(70));
console.log(" 🎉 UPGRADE PROPOSAL SUBMITTED!");
console.log("═".repeat(70));
console.log(` Contract: ${contractId}`);
console.log(` Version: ${currentVersion}${newVersion}`);
console.log(` Network: ${network}`);
console.log(` Implementation: ${implementationAddress}`);
console.log(` Safe TX Hash: ${result.safeTxHash}`);
console.log("═".repeat(70));
console.log("\n Next steps:");
console.log(` 1. Other signers approve at:`);
console.log(` ${safeUrl}`);
console.log(` 2. Once ${governance.securityThreshold} signatures collected → click 'Execute'`);
console.log("");
} else if (result.isOwner && !result.proposed) {
// Owner but failed to propose
log.warning(`You ARE a signer but auto-propose failed: ${result.error}`);
log.info("Please submit manually via Safe UI");
outputManualSubmissionData(
contractId,
currentVersion,
newVersion,
network,
implementationAddress,
governance.securityMultisig,
governance.securityThreshold,
proxyAddress,
upgradeData,
);
} else {
// Not an owner
if (result.error) {
log.info(`Could not check Safe ownership: ${result.error}`);
} else {
log.info(`You are NOT a signer on the multisig (${shortenAddress(deployerAddress)})`);
}
log.info("Please submit manually via Safe UI");
outputManualSubmissionData(
contractId,
currentVersion,
newVersion,
network,
implementationAddress,
governance.securityMultisig,
governance.securityThreshold,
proxyAddress,
upgradeData,
);
}
});
/**
* Output data for manual Safe submission
*/
function outputManualSubmissionData(
contractId: string,
currentVersion: string,
newVersion: string,
network: SupportedNetwork,
implementationAddress: string,
safeAddress: string,
threshold: string,
proxyAddress: string,
upgradeData: string,
): void {
const chainPrefix = getChainPrefix(network);
const safeUrl = `https://app.safe.global/apps/open?safe=${chainPrefix}:${safeAddress}&appUrl=https%3A%2F%2Fapps-portal.safe.global%2Ftx-builder`;
console.log("\n");
console.log("═".repeat(70));
console.log(" 📋 SUBMIT UPGRADE TO SAFE MULTISIG");
console.log("═".repeat(70));
console.log(` Contract: ${contractId}`);
console.log(` Version: ${currentVersion}${newVersion}`);
console.log(` Network: ${network}`);
console.log(` New Impl: ${implementationAddress}`);
console.log("═".repeat(70));
console.log("\n┌─────────────────────────────────────────────────────────────────────┐");
console.log("│ STEP 1: Open Safe Transaction Builder │");
console.log("└─────────────────────────────────────────────────────────────────────┘");
console.log(`\n ${safeUrl}\n`);
console.log("┌─────────────────────────────────────────────────────────────────────┐");
console.log("│ STEP 2: Toggle 'Custom data' switch ON (top right) │");
console.log("└─────────────────────────────────────────────────────────────────────┘");
console.log("\n┌─────────────────────────────────────────────────────────────────────┐");
console.log("│ STEP 3: Enter transaction data │");
console.log("└─────────────────────────────────────────────────────────────────────┘");
console.log("\n TO ADDRESS:");
console.log(` ${proxyAddress}`);
console.log("\n ETH VALUE:");
console.log(" 0");
console.log("\n DATA (HEX):");
console.log(` ${upgradeData}`);
console.log("\n┌─────────────────────────────────────────────────────────────────────┐");
console.log("│ STEP 4: Click '+ Add new transaction' │");
console.log("└─────────────────────────────────────────────────────────────────────┘");
console.log("\n┌─────────────────────────────────────────────────────────────────────┐");
console.log("│ STEP 5: Click 'Create Batch' (green button, right side) │");
console.log("└─────────────────────────────────────────────────────────────────────┘");
console.log("\n┌─────────────────────────────────────────────────────────────────────┐");
console.log("│ STEP 6: Click 'Send Batch' → then 'Continue' in the modal │");
console.log("└─────────────────────────────────────────────────────────────────────┘");
console.log("\n┌─────────────────────────────────────────────────────────────────────┐");
console.log("│ STEP 7: Sign with your wallet (this adds to queue) │");
console.log("└─────────────────────────────────────────────────────────────────────┘");
console.log("\n┌─────────────────────────────────────────────────────────────────────┐");
console.log(`│ STEP 8: Other signers sign (${threshold} required)`.padEnd(70) + "│");
console.log("└─────────────────────────────────────────────────────────────────────┘");
console.log("\n┌─────────────────────────────────────────────────────────────────────┐");
console.log("│ STEP 9: Click 'Execute' once all signatures collected │");
console.log("└─────────────────────────────────────────────────────────────────────┘");
console.log("\n" + "═".repeat(70));
console.log(" Alternative: Use ABI method (if 'Custom data' is OFF)");
console.log("═".repeat(70));
console.log(" 1. Select 'upgradeToAndCall' from Contract Method Selector");
console.log(` 2. newImplementation: ${implementationAddress}`);
console.log(" 3. data: 0x");
console.log("═".repeat(70));
console.log("");
}
export {};