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
This commit is contained in:
Evi Nova
2025-12-10 04:30:50 -03:00
committed by GitHub
parent fc82b6b2b3
commit bc4e52bb1e
49 changed files with 7675 additions and 208 deletions

View File

@@ -0,0 +1,179 @@
# Upgrade Tooling
A comprehensive toolset for safely upgrading UUPS proxy contracts in the Self Protocol.
## Overview
The upgrade tooling provides:
- **Safety checks** - Storage layout validation, version validation, reinitializer verification
- **Safe multisig integration** - Creates proposals for SECURITY_ROLE approval
- **Version tracking** - Automatic registry updates and git tagging
- **Audit trail** - Complete deployment history with changelogs
## Quick Start
```bash
# Single command to validate, deploy, and propose
npx hardhat upgrade --contract IdentityVerificationHub --network celo --changelog "Added feature X"
```
## The `upgrade` Command
Validates, deploys, and creates a Safe multisig proposal in one step.
```bash
npx hardhat upgrade \
--contract <ContractId> \
--network <network> \
[--changelog <message>] \
[--prepare-only]
```
**Options:**
- `--contract` - Contract to upgrade (IdentityVerificationHub, IdentityRegistry, etc.)
- `--network` - Target network (celo, sepolia, localhost)
- `--changelog` - Description of changes
- `--prepare-only` - Deploy implementation without creating Safe proposal
**What it does:**
1. ✅ Validates `@custom:version` increment
2. ✅ Checks `reinitializer(N)` matches expected version
3. ✅ Validates storage layout compatibility
4. ✅ Clears cache and compiles fresh
5. ✅ Compares bytecode (warns if unchanged)
6. ✅ Deploys new implementation
7. ✅ Updates deployment registry
8. ✅ Creates git commit and tag
9. ✅ Creates Safe proposal (or outputs manual instructions)
## Utility Commands
```bash
# Check current deployment status
npx hardhat upgrade:status --contract IdentityVerificationHub --network celo
# View version history
npx hardhat upgrade:history --contract IdentityVerificationHub
```
## Workflow
### For Developers
```
┌─────────────────────────────────────────────────────────────────────┐
│ 1. UPDATE CONTRACT CODE │
│ - Make your changes │
│ - Update @custom:version in NatSpec │
│ - Increment reinitializer(N) modifier │
│ - Add new storage fields at END of struct only │
├─────────────────────────────────────────────────────────────────────┤
│ 2. RUN: npx hardhat upgrade --contract X --network Y --changelog Z │
│ - Validates all safety checks │
│ - Deploys new implementation │
│ - Updates registry.json │
│ - Creates git commit + tag │
│ - Creates Safe proposal │
├─────────────────────────────────────────────────────────────────────┤
│ 3. MULTISIG APPROVAL │
│ - Signers review in Safe UI │
│ - Once threshold met, click Execute │
└─────────────────────────────────────────────────────────────────────┘
```
### Contract Update Pattern
```solidity
/**
* @title MyContract
* @custom:version 2.13.0 // <-- Update this
*/
contract MyContract is ImplRoot {
struct MyStorage {
uint256 existingField;
uint256 newField; // <-- Add new fields at end only
}
// Increment reinitializer(N) for each upgrade
function initialize(...) external reinitializer(13) {
// Initialize new fields if needed
MyStorage storage $ = _getMyStorage();
if ($.newField == 0) {
$.newField = defaultValue;
}
}
}
```
## Configuration
### Deployment Registry
The registry (`deployments/registry.json`) tracks:
- Proxy addresses per network
- Current versions
- Implementation history
- Git commits and tags
### Governance Configuration
Multisig addresses are configured in `deployments/registry.json`:
```json
{
"networks": {
"celo": {
"governance": {
"securityMultisig": "0x...",
"operationsMultisig": "0x...",
"securityThreshold": "3/5",
"operationsThreshold": "2/5"
}
}
}
}
```
### Environment Variables
Required for deployments:
```bash
PRIVATE_KEY=0x... # Deployer private key
CELO_RPC_URL=https://... # RPC endpoint
```
## Supported Contracts
| Contract ID | Contract Name | Type |
| ------------------------- | ----------------------------- | ---------- |
| `IdentityVerificationHub` | IdentityVerificationHubImplV2 | UUPS Proxy |
| `IdentityRegistry` | IdentityRegistryImplV1 | UUPS Proxy |
| `IdentityRegistryIdCard` | IdentityRegistryIdCardImplV1 | UUPS Proxy |
| `IdentityRegistryAadhaar` | IdentityRegistryAadhaarImplV1 | UUPS Proxy |
## Safety Checks
| Check | What it Does | Failure Behavior |
| ---------------------- | ------------------------------------------- | -------------------- |
| Version validation | Ensures semantic version increment | Blocks upgrade |
| Reinitializer check | Verifies `reinitializer(N)` matches version | Blocks upgrade |
| Storage layout | Detects breaking storage changes | Blocks upgrade |
| Bytecode comparison | Warns if code unchanged | Prompts confirmation |
| Safe role verification | Confirms Safe has SECURITY_ROLE | Blocks upgrade |
| Constructor check | Flags `_disableInitializers()` | Prompts confirmation |
## Troubleshooting
| Issue | Solution |
| ----------------------------- | ----------------------------------------- |
| "Version matches current" | Update `@custom:version` in contract |
| "Reinitializer mismatch" | Update `reinitializer(N)` to next version |
| "Storage layout incompatible" | Don't remove/reorder storage variables |
| "Safe not indexed" | Submit manually via Safe UI |
| "Bytecode unchanged" | Ensure you saved contract changes |

View File

@@ -0,0 +1,91 @@
/**
* upgrade:history task
*
* Shows deployment history for a contract.
*/
import { task, types } from "hardhat/config";
import { log, readRegistry, getContractDefinition, compareVersions, shortenAddress } from "./utils";
import { CONTRACT_IDS, ContractId } from "./types";
interface HistoryTaskArgs {
contract: ContractId;
}
task("upgrade:history", "Show deployment history for a contract")
.addParam("contract", `Contract to show history for (${CONTRACT_IDS.join(", ")})`, undefined, types.string)
.setAction(async (args: HistoryTaskArgs) => {
const { contract: contractId } = args;
log.header(`DEPLOYMENT HISTORY: ${contractId}`);
if (!CONTRACT_IDS.includes(contractId as ContractId)) {
log.error(`Invalid contract: ${contractId}`);
return;
}
const registry = readRegistry();
const contractDef = getContractDefinition(contractId);
const versions = registry.versions[contractId] || {};
// Contract info
console.log("\n📋 Contract Information");
console.log("─".repeat(60));
log.detail("Source", contractDef.source);
log.detail("Type", contractDef.type);
log.detail("Description", contractDef.description);
// Network deployments
console.log("\n🔗 Network Deployments");
console.log("─".repeat(60));
for (const [networkName, networkConfig] of Object.entries(registry.networks)) {
const deployment = networkConfig.deployments[contractId];
if (deployment) {
const address = deployment.proxy || deployment.address || "Not deployed";
const version = deployment.currentVersion || "N/A";
console.log(` ${networkName.padEnd(15)} ${shortenAddress(address).padEnd(15)} v${version}`);
}
}
// Version history
console.log("\n📜 Version History");
console.log("─".repeat(60));
const versionNumbers = Object.keys(versions).sort((a, b) => compareVersions(b, a));
if (versionNumbers.length === 0) {
console.log(" No versions recorded");
}
for (const version of versionNumbers) {
const info = versions[version];
const isCurrent = Object.values(registry.networks).some(
(n) => n.deployments[contractId]?.currentVersion === version,
);
console.log(`\n ${isCurrent ? "→" : " "} v${version} (Initializer v${info.initializerVersion})`);
if (isCurrent) {
console.log(" CURRENT");
}
console.log(" " + "─".repeat(50));
console.log(` Changelog: ${info.changelog}`);
console.log(` Initializer: ${info.initializerFunction}()`);
console.log(` Git tag: ${info.gitTag}`);
// Show deployments per network
if (info.deployments && Object.keys(info.deployments).length > 0) {
console.log(" Deployments:");
for (const [network, deployment] of Object.entries(info.deployments)) {
if (deployment.impl) {
const date = deployment.deployedAt ? new Date(deployment.deployedAt).toLocaleString() : "Unknown";
console.log(` ${network}: ${shortenAddress(deployment.impl)} (${date})`);
}
}
}
}
console.log("\n");
});
export {};

View File

@@ -0,0 +1,14 @@
/**
* Upgrade Tasks
*
* Main entry point for contract upgrade tooling.
*/
// Import all upgrade tasks
import "./upgrade";
import "./prepare";
import "./propose";
import "./status";
import "./history";
export {};

View File

@@ -0,0 +1,403 @@
/**
* upgrade:prepare task
*
* Validates and deploys a new implementation contract.
* Does NOT execute the upgrade - that requires multisig approval.
*
* Features:
* - Auto-validates version increment (must be current + 1)
* - Auto-updates @custom:version in contract if needed
* - Auto-commits after successful deployment
* - Records git commit hash and creates tag
*
* Usage:
* npx hardhat upgrade:prepare --contract IdentityVerificationHub --network celo
*/
import { task, types } from "hardhat/config";
import { HardhatRuntimeEnvironment } from "hardhat/types";
import {
log,
getContractDefinition,
getProxyAddress,
getCurrentVersion,
getGitCommitShort,
getGitBranch,
hasUncommittedChanges,
validateVersionIncrement,
suggestNextVersion,
readContractVersion,
updateContractVersion,
getContractFilePath,
addVersion,
getExplorerUrl,
shortenAddress,
createGitTag,
gitCommit,
getLatestVersionInfo,
} from "./utils";
import { CONTRACT_IDS, ContractId, SupportedNetwork } from "./types";
interface PrepareTaskArgs {
contract: ContractId;
newVersion?: string;
changelog?: string;
dryRun: boolean;
skipCommit: boolean;
}
task("upgrade:prepare", "Validate and deploy a new implementation contract")
.addParam("contract", `Contract to upgrade (${CONTRACT_IDS.join(", ")})`, undefined, types.string)
.addOptionalParam(
"newVersion",
"New version - auto-detected from contract file if not provided",
undefined,
types.string,
)
.addOptionalParam("changelog", "Changelog entry for this version", undefined, types.string)
.addFlag("dryRun", "Simulate the deployment without actually deploying")
.addFlag("skipCommit", "Skip auto-commit after deployment")
.setAction(async (args: PrepareTaskArgs, hre: HardhatRuntimeEnvironment) => {
const { contract: contractId, changelog, dryRun, skipCommit } = args;
let { newVersion } = args;
const network = hre.network.name as SupportedNetwork;
log.header(`UPGRADE PREPARE: ${contractId}`);
log.detail("Network", network);
log.detail("Mode", dryRun ? "DRY RUN (no actual deployment)" : "LIVE DEPLOYMENT");
// ========================================================================
// 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);
}
// If no version provided, use contract file version
if (!newVersion) {
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: Update contract file version if needed
// ========================================================================
if (contractFilePath && contractFileVersion !== newVersion) {
log.step("Updating contract file version...");
if (dryRun) {
log.info(`[DRY RUN] Would update ${contractFilePath} to version ${newVersion}`);
} else {
const updated = updateContractVersion(contractFilePath, newVersion);
if (updated) {
log.success(`Updated @custom:version to ${newVersion} in contract file`);
} else {
log.warning("Could not update version in contract file - please update manually");
}
}
}
// ========================================================================
// 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: Load and validate the new implementation
// ========================================================================
log.step("Loading contract factory...");
const contractName = contractDef.source;
let ContractFactory;
try {
// Handle contracts that need library linking
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 6: Validate storage layout
// ========================================================================
log.step("Validating storage layout compatibility...");
try {
await hre.upgrades.validateImplementation(ContractFactory, {
kind: "uups",
unsafeAllowLinkedLibraries: true,
unsafeAllow: ["constructor", "external-library-linking"],
});
log.success("Storage layout validation passed");
} catch (error) {
log.error(`Storage layout validation failed: ${error}`);
return;
}
// ========================================================================
// Step 7: Simulate upgrade on fork
// ========================================================================
log.step("Simulating upgrade on fork...");
try {
const proxyContract = await hre.ethers.getContractAt(contractName, proxyAddress);
const SECURITY_ROLE = await proxyContract.SECURITY_ROLE();
log.detail("SECURITY_ROLE", SECURITY_ROLE);
log.success("Fork simulation passed - proxy is accessible");
} catch (error) {
log.error(`Fork simulation failed: ${error}`);
return;
}
// ========================================================================
// Step 8: Dry run summary
// ========================================================================
if (dryRun) {
log.step("DRY RUN - Skipping actual deployment");
log.box([
"DRY RUN SUMMARY",
"─".repeat(50),
`Contract: ${contractId}`,
`Version: ${currentVersion}${newVersion}`,
`Network: ${network}`,
`Proxy: ${proxyAddress}`,
"",
"What would happen:",
`1. Update contract file to version ${newVersion}`,
"2. Deploy new implementation",
"3. Update registry.json",
"4. Create git commit and tag",
"",
"Run without --dry-run to execute.",
]);
return;
}
// ========================================================================
// Step 9: Deploy new implementation
// ========================================================================
log.step("Deploying new implementation...");
try {
const implementation = await ContractFactory.deploy();
await implementation.waitForDeployment();
const implementationAddress = await implementation.getAddress();
log.success(`Implementation deployed: ${implementationAddress}`);
log.detail("Explorer", `${getExplorerUrl(network)}/address/${implementationAddress}`);
// ========================================================================
// Step 10: 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 11: Update registry
// ========================================================================
log.step("Updating deployment registry...");
// Get previous version info to determine initializer version
const latestVersion = getLatestVersionInfo(contractId);
const newInitializerVersion = (latestVersion?.info.initializerVersion || 0) + 1;
const gitCommitShort = getGitCommitShort();
const deployerAddress = (await hre.ethers.provider.getSigner()).address;
addVersion(
contractId,
network,
newVersion,
{
initializerVersion: newInitializerVersion,
initializerFunction: newInitializerVersion === 1 ? "initialize" : `initializeV${newInitializerVersion}`,
changelog: changelog || `Upgrade to v${newVersion}`,
gitTag: `${contractId.toLowerCase()}-v${newVersion}`,
},
{
impl: implementationAddress,
deployedAt: new Date().toISOString(),
deployedBy: deployerAddress,
gitCommit: "", // Will be set after commit
},
);
log.success("Registry updated");
// ========================================================================
// Step 12: 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}`);
// Try to create git tag
try {
createGitTag(
`${contractId.toLowerCase()}-v${newVersion}`,
`${contractId} v${newVersion} - ${changelog || "Upgrade"}`,
);
log.success(`Created git tag: ${contractId.toLowerCase()}-v${newVersion}`);
} catch (e) {
log.warning("Could not create git tag - you can create it manually");
}
} else {
log.warning("Could not create git commit - please commit manually");
}
}
// ========================================================================
// Summary
// ========================================================================
log.box([
"DEPLOYMENT SUCCESSFUL",
"═".repeat(50),
`Contract: ${contractId}`,
`Version: ${currentVersion}${newVersion}`,
`Network: ${network}`,
"",
"Addresses:",
` Proxy: ${shortenAddress(proxyAddress)}`,
` New Impl: ${shortenAddress(implementationAddress)}`,
"",
"Next steps:",
` 1. Run: npx hardhat upgrade:propose --contract ${contractId} --network ${network}`,
" 2. Multisig signers approve in Safe UI",
" 3. Transaction executes automatically when threshold reached",
]);
} catch (error) {
log.error(`Deployment failed: ${error}`);
return;
}
});
export {};

View File

@@ -0,0 +1,201 @@
/**
* upgrade:propose task
*
* Creates a Safe multisig transaction to execute the upgrade.
* The implementation must already be deployed via upgrade:prepare.
*
* Usage:
* npx hardhat upgrade:propose --contract IdentityVerificationHub --network celo
*/
import { task, types } from "hardhat/config";
import { HardhatRuntimeEnvironment } from "hardhat/types";
import {
log,
getContractDefinition,
getProxyAddress,
getCurrentVersion,
getGovernanceConfig,
getVersionInfo,
getNetworkDeployment,
} from "./utils";
import { CONTRACT_IDS, ContractId, SupportedNetwork } from "./types";
/**
* Get Safe chain prefix for URL
*/
function getChainPrefix(network: SupportedNetwork): string {
const prefixes: Record<SupportedNetwork, string> = {
celo: "celo",
"celo-sepolia": "celo",
sepolia: "sep",
localhost: "eth",
};
return prefixes[network] || network;
}
interface ProposeTaskArgs {
contract: ContractId;
dryRun: boolean;
}
task("upgrade:propose", "Create Safe transaction to execute the upgrade")
.addParam("contract", `Contract to upgrade (${CONTRACT_IDS.join(", ")})`, undefined, types.string)
.addFlag("dryRun", "Generate transaction data without submitting to Safe")
.setAction(async (args: ProposeTaskArgs, hre: HardhatRuntimeEnvironment) => {
const { contract: contractId, dryRun } = args;
const network = hre.network.name as SupportedNetwork;
log.header(`UPGRADE PROPOSE: ${contractId}`);
log.detail("Network", network);
log.detail("Mode", dryRun ? "DRY RUN (no Safe submission)" : "LIVE SUBMISSION");
// ========================================================================
// Step 1: Validate inputs
// ========================================================================
log.step("Validating inputs...");
if (!CONTRACT_IDS.includes(contractId as ContractId)) {
log.error(`Invalid contract: ${contractId}`);
return;
}
const contractDef = getContractDefinition(contractId);
if (contractDef.type !== "uups-proxy") {
log.error(`Contract '${contractId}' is not upgradeable`);
return;
}
let proxyAddress: string;
try {
proxyAddress = getProxyAddress(contractId, network);
} catch {
log.error(`No proxy deployed for '${contractId}' on '${network}'`);
return;
}
const currentVersion = getCurrentVersion(contractId, network);
const deployment = getNetworkDeployment(contractId, network);
const newImplAddress = deployment?.currentImpl;
if (!newImplAddress) {
log.error("No implementation deployed. Run upgrade:prepare first.");
return;
}
const versionInfo = getVersionInfo(contractId, currentVersion);
log.detail("Contract", contractId);
log.detail("Proxy", proxyAddress);
log.detail("Current version", currentVersion);
log.detail("New implementation", newImplAddress);
log.detail("Changelog", versionInfo?.changelog || "N/A");
// ========================================================================
// Step 2: Load governance configuration
// ========================================================================
log.step("Loading governance configuration...");
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");
return;
}
log.detail("Security multisig", governance.securityMultisig);
log.detail("Required threshold", governance.securityThreshold);
// ========================================================================
// Step 3: Verify implementation contract
// ========================================================================
log.step("Verifying implementation contract...");
const implCode = await hre.ethers.provider.getCode(newImplAddress);
if (implCode === "0x") {
log.error(`No contract found at implementation address ${newImplAddress}`);
return;
}
log.success("Implementation contract verified on-chain");
// ========================================================================
// Step 4: Build upgrade transaction
// ========================================================================
log.step("Building upgrade transaction...");
const contractName = contractDef.source;
const proxyContract = await hre.ethers.getContractAt(contractName, proxyAddress);
// Check if there's an initializer to call
let initData = "0x";
const initializerName = versionInfo?.initializerFunction;
if (initializerName && initializerName !== "initialize") {
try {
const iface = proxyContract.interface;
const initFragment = iface.getFunction(initializerName);
if (initFragment) {
initData = iface.encodeFunctionData(initializerName, []);
log.detail("Initialization", initializerName);
}
} catch {
log.detail("Initialization", "None (function not found)");
}
} else {
log.detail("Initialization", "None");
}
// Encode upgradeToAndCall
const upgradeData = proxyContract.interface.encodeFunctionData("upgradeToAndCall", [newImplAddress, initData]);
log.detail("Method", "upgradeToAndCall(address,bytes)");
log.detail("Target", proxyAddress);
// ========================================================================
// Step 5: Output transaction data
// ========================================================================
if (dryRun) {
log.step("DRY RUN - Transaction data generated");
} else {
log.step("Generating Safe proposal data...");
}
const chainPrefix = getChainPrefix(network);
log.success("Transaction data generated");
log.box([
"SAFE PROPOSAL READY",
"═".repeat(60),
`Safe: ${governance.securityMultisig}`,
`Threshold: ${governance.securityThreshold}`,
"",
"Transaction:",
` To: ${proxyAddress}`,
" Value: 0",
` Data: ${upgradeData.slice(0, 50)}...`,
"",
"Submit via Safe UI:",
` 1. Go to: https://app.safe.global/home?safe=${chainPrefix}:${governance.securityMultisig}`,
" 2. Click 'New transaction' → 'Transaction Builder'",
" 3. Enter: To, Value, Data from above",
` 4. ${governance.securityThreshold} signers approve`,
" 5. Upgrade executes automatically!",
]);
// Output raw data for copy-paste
console.log("\n📋 Raw transaction data (copy this for Transaction Builder):");
console.log("─".repeat(60));
console.log(
JSON.stringify(
{
to: proxyAddress,
value: "0",
data: upgradeData,
},
null,
2,
),
);
});
export {};

View File

@@ -0,0 +1,113 @@
/**
* upgrade:status task
*
* Shows current deployment status for a contract.
*/
import { task, types } from "hardhat/config";
import { HardhatRuntimeEnvironment } from "hardhat/types";
import {
log,
getContractDefinition,
getNetworkDeployment,
getGovernanceConfig,
getVersionInfo,
shortenAddress,
getExplorerUrl,
} from "./utils";
import { CONTRACT_IDS, ContractId, SupportedNetwork } from "./types";
interface StatusTaskArgs {
contract: ContractId;
}
task("upgrade:status", "Show current deployment status for a contract")
.addParam("contract", `Contract to check (${CONTRACT_IDS.join(", ")})`, undefined, types.string)
.setAction(async (args: StatusTaskArgs, hre: HardhatRuntimeEnvironment) => {
const { contract: contractId } = args;
const network = hre.network.name as SupportedNetwork;
log.header(`STATUS: ${contractId} on ${network}`);
if (!CONTRACT_IDS.includes(contractId as ContractId)) {
log.error(`Invalid contract: ${contractId}`);
return;
}
const contractDef = getContractDefinition(contractId);
const deployment = getNetworkDeployment(contractId, network);
const governance = getGovernanceConfig(network);
console.log("\n📋 Contract Info");
console.log("─".repeat(60));
log.detail("Source", contractDef.source);
log.detail("Type", contractDef.type);
if (!deployment) {
log.warning(`Not deployed on ${network}`);
return;
}
const currentVersion = deployment.currentVersion;
const proxyAddress = deployment.proxy || deployment.address;
const implAddress = deployment.currentImpl;
const versionInfo = currentVersion ? getVersionInfo(contractId, currentVersion) : null;
console.log("\n🔗 Deployment");
console.log("─".repeat(60));
log.detail("Proxy", proxyAddress || "N/A");
log.detail("Implementation", implAddress || "N/A");
log.detail("Current version", currentVersion || "N/A");
if (proxyAddress) {
log.detail("Explorer", `${getExplorerUrl(network)}/address/${proxyAddress}`);
}
if (versionInfo) {
console.log("\n📌 Version Info");
console.log("─".repeat(60));
log.detail("Changelog", versionInfo.changelog);
log.detail("Initializer", versionInfo.initializerFunction);
log.detail("Git tag", versionInfo.gitTag);
}
console.log("\n🔐 Governance");
console.log("─".repeat(60));
log.detail("Security multisig", governance.securityMultisig || "Not configured");
log.detail("Security threshold", governance.securityThreshold);
log.detail("Operations multisig", governance.operationsMultisig || "Not configured");
log.detail("Operations threshold", governance.operationsThreshold);
// Check on-chain state if connected
if (proxyAddress && contractDef.type === "uups-proxy") {
try {
const proxy = await hre.ethers.getContractAt(contractDef.source, proxyAddress);
console.log("\n⛓ On-Chain State");
console.log("─".repeat(60));
// Try to read version
try {
const onChainVersion = await proxy.version();
log.detail("On-chain version", onChainVersion);
} catch {
log.detail("On-chain version", "N/A");
}
// Check role holders
try {
const SECURITY_ROLE = await proxy.SECURITY_ROLE();
const OPERATIONS_ROLE = await proxy.OPERATIONS_ROLE();
log.detail("SECURITY_ROLE", shortenAddress(SECURITY_ROLE));
log.detail("OPERATIONS_ROLE", shortenAddress(OPERATIONS_ROLE));
} catch {
// Not all contracts have these
}
} catch (error) {
log.warning(`Could not read on-chain state: ${error}`);
}
}
console.log("\n");
});
export {};

View File

@@ -0,0 +1,29 @@
/**
* Types for upgrade tooling
*/
export const SUPPORTED_NETWORKS = ["celo", "celo-sepolia", "sepolia", "localhost"] as const;
export type SupportedNetwork = (typeof SUPPORTED_NETWORKS)[number];
// Contract IDs match registry keys
export const CONTRACT_IDS = [
"IdentityVerificationHub",
"IdentityRegistry",
"IdentityRegistryIdCard",
"IdentityRegistryAadhaar",
"PCR0Manager",
"VerifyAll",
"DummyContract",
] as const;
export type ContractId = (typeof CONTRACT_IDS)[number];
// Re-export types from utils for convenience
export type {
ContractDefinition,
NetworkConfig,
NetworkDeployment,
GovernanceConfig,
VersionInfo,
VersionDeployment,
DeploymentRegistry,
} from "./utils";

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,612 @@
/**
* Utility functions for upgrade tooling
*
* Works with the new registry structure:
* - contracts: Contract definitions (source, type, description)
* - networks: Per-network deployments and governance
* - versions: Version history with deployment details
*/
import * as fs from "fs";
import * as path from "path";
import { execSync } from "child_process";
import { SupportedNetwork } from "./types";
// Registry types matching the new structure
export interface ContractDefinition {
source: string;
type: "uups-proxy" | "non-upgradeable";
description: string;
}
export interface NetworkDeployment {
proxy?: string;
address?: string;
currentVersion: string;
currentImpl?: string;
}
export interface GovernanceConfig {
securityMultisig: string;
operationsMultisig: string;
securityThreshold: string;
operationsThreshold: string;
}
export interface NetworkConfig {
chainId: number;
governance: GovernanceConfig;
deployments: Record<string, NetworkDeployment>;
}
export interface VersionDeployment {
impl: string;
deployedAt: string;
deployedBy: string;
gitCommit: string;
}
export interface VersionInfo {
initializerVersion: number;
initializerFunction: string;
changelog: string;
gitTag: string;
deployments: Record<string, VersionDeployment>;
}
export interface DeploymentRegistry {
$schema: string;
lastUpdated: string;
contracts: Record<string, ContractDefinition>;
networks: Record<string, NetworkConfig>;
versions: Record<string, Record<string, VersionInfo>>;
}
// Console colors for pretty output
const colors = {
reset: "\x1b[0m",
bold: "\x1b[1m",
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
magenta: "\x1b[35m",
cyan: "\x1b[36m",
white: "\x1b[37m",
gray: "\x1b[90m",
};
export const log = {
info: (msg: string) => console.log(`${colors.blue}${colors.reset} ${msg}`),
success: (msg: string) => console.log(`${colors.green}${colors.reset} ${msg}`),
warning: (msg: string) => console.log(`${colors.yellow}⚠️${colors.reset} ${msg}`),
error: (msg: string) => console.log(`${colors.red}${colors.reset} ${msg}`),
step: (msg: string) => console.log(`\n${colors.magenta}🔄${colors.reset} ${colors.bold}${msg}${colors.reset}`),
header: (msg: string) =>
console.log(
`\n${colors.cyan}${"═".repeat(70)}${colors.reset}\n${colors.bold}${msg}${colors.reset}\n${colors.cyan}${"═".repeat(70)}${colors.reset}`,
),
detail: (label: string, value: string) => console.log(` ${colors.gray}${label}:${colors.reset} ${value}`),
box: (lines: string[]) => {
const maxLen = Math.max(...lines.map((l) => l.length));
console.log(`\n┌${"─".repeat(maxLen + 2)}`);
lines.forEach((line) => console.log(`${line.padEnd(maxLen)}`));
console.log(`${"─".repeat(maxLen + 2)}\n`);
},
};
/**
* Get the path to the deployment registry
*/
export function getRegistryPath(): string {
return path.join(__dirname, "../../deployments/registry.json");
}
/**
* Read the deployment registry
*/
export function readRegistry(): DeploymentRegistry {
const registryPath = getRegistryPath();
if (!fs.existsSync(registryPath)) {
throw new Error(`Deployment registry not found at ${registryPath}`);
}
return JSON.parse(fs.readFileSync(registryPath, "utf-8"));
}
/**
* Write the deployment registry
*/
export function writeRegistry(registry: DeploymentRegistry): void {
const registryPath = getRegistryPath();
registry.lastUpdated = new Date().toISOString();
fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2) + "\n");
}
/**
* Get contract definition from registry
*/
export function getContractDefinition(contractId: string): ContractDefinition {
const registry = readRegistry();
const contract = registry.contracts[contractId];
if (!contract) {
throw new Error(`Contract '${contractId}' not found in registry`);
}
return contract;
}
/**
* Get network config
*/
export function getNetworkConfig(network: SupportedNetwork): NetworkConfig {
const registry = readRegistry();
const networkConfig = registry.networks[network];
if (!networkConfig) {
throw new Error(`Network '${network}' not configured in registry`);
}
return networkConfig;
}
/**
* Get deployment for a contract on a network
*/
export function getNetworkDeployment(contractId: string, network: SupportedNetwork): NetworkDeployment | null {
const registry = readRegistry();
return registry.networks[network]?.deployments?.[contractId] || null;
}
/**
* Get proxy address for a contract on a network
*/
export function getProxyAddress(contractId: string, network: SupportedNetwork): string {
const deployment = getNetworkDeployment(contractId, network);
if (!deployment?.proxy) {
throw new Error(`No proxy address found for '${contractId}' on network '${network}'`);
}
return deployment.proxy;
}
/**
* Get current version for a contract on a network
*/
export function getCurrentVersion(contractId: string, network: SupportedNetwork): string {
const deployment = getNetworkDeployment(contractId, network);
return deployment?.currentVersion || "0.0.0";
}
/**
* Get governance config for a network
*/
export function getGovernanceConfig(network: SupportedNetwork): GovernanceConfig {
const networkConfig = getNetworkConfig(network);
return networkConfig.governance;
}
/**
* Get version info from registry
*/
export function getVersionInfo(contractId: string, version: string): VersionInfo | null {
const registry = readRegistry();
return registry.versions[contractId]?.[version] || null;
}
/**
* Get latest version info for a contract
*/
export function getLatestVersionInfo(contractId: string): { version: string; info: VersionInfo } | null {
const registry = readRegistry();
const versions = registry.versions[contractId];
if (!versions) return null;
const versionNumbers = Object.keys(versions).sort((a, b) => compareVersions(b, a));
if (versionNumbers.length === 0) return null;
return { version: versionNumbers[0], info: versions[versionNumbers[0]] };
}
/**
* Add new version to registry
*/
export function addVersion(
contractId: string,
network: SupportedNetwork,
version: string,
versionInfo: Omit<VersionInfo, "deployments">,
deployment: VersionDeployment,
): void {
const registry = readRegistry();
// Initialize versions object if needed
if (!registry.versions[contractId]) {
registry.versions[contractId] = {};
}
// Add or update version info
if (!registry.versions[contractId][version]) {
registry.versions[contractId][version] = {
...versionInfo,
deployments: {},
};
}
registry.versions[contractId][version].deployments[network] = deployment;
// Update network deployment
if (!registry.networks[network]) {
throw new Error(`Network '${network}' not configured`);
}
if (!registry.networks[network].deployments[contractId]) {
registry.networks[network].deployments[contractId] = {
proxy: "",
currentVersion: "",
currentImpl: "",
};
}
registry.networks[network].deployments[contractId].currentVersion = version;
registry.networks[network].deployments[contractId].currentImpl = deployment.impl;
writeRegistry(registry);
}
/**
* Update proxy address for a contract on a network
*/
export function setProxyAddress(contractId: string, network: SupportedNetwork, proxyAddress: string): void {
const registry = readRegistry();
if (!registry.networks[network]) {
throw new Error(`Network '${network}' not configured`);
}
if (!registry.networks[network].deployments[contractId]) {
registry.networks[network].deployments[contractId] = {
proxy: "",
currentVersion: "",
currentImpl: "",
};
}
registry.networks[network].deployments[contractId].proxy = proxyAddress;
writeRegistry(registry);
}
/**
* Get current git commit hash
*/
export function getGitCommit(): string {
try {
return execSync("git rev-parse HEAD").toString().trim();
} catch {
return "unknown";
}
}
/**
* Get short git commit hash
*/
export function getGitCommitShort(): string {
try {
return execSync("git rev-parse --short HEAD").toString().trim();
} catch {
return "unknown";
}
}
/**
* Get current git branch
*/
export function getGitBranch(): string {
try {
return execSync("git rev-parse --abbrev-ref HEAD").toString().trim();
} catch {
return "unknown";
}
}
/**
* Check if there are uncommitted changes
*/
export function hasUncommittedChanges(): boolean {
try {
const status = execSync("git status --porcelain").toString().trim();
return status.length > 0;
} catch {
return false;
}
}
/**
* Create a git tag
*/
export function createGitTag(tag: string, message: string): void {
execSync(`git tag -a ${tag} -m "${message}"`);
}
/**
* Parse semantic version
*/
export function parseVersion(version: string): { major: number; minor: number; patch: number } {
const match = version.match(/^(\d+)\.(\d+)\.(\d+)$/);
if (!match) {
throw new Error(`Invalid version format: ${version}`);
}
return {
major: parseInt(match[1]),
minor: parseInt(match[2]),
patch: parseInt(match[3]),
};
}
/**
* Compare versions (returns 1 if a > b, -1 if a < b, 0 if equal)
*/
export function compareVersions(a: string, b: string): number {
const va = parseVersion(a);
const vb = parseVersion(b);
if (va.major !== vb.major) return va.major > vb.major ? 1 : -1;
if (va.minor !== vb.minor) return va.minor > vb.minor ? 1 : -1;
if (va.patch !== vb.patch) return va.patch > vb.patch ? 1 : -1;
return 0;
}
/**
* Increment version
*/
export function incrementVersion(version: string, type: "major" | "minor" | "patch"): string {
const v = parseVersion(version);
switch (type) {
case "major":
return `${v.major + 1}.0.0`;
case "minor":
return `${v.major}.${v.minor + 1}.0`;
case "patch":
return `${v.major}.${v.minor}.${v.patch + 1}`;
}
}
/**
* Suggest next version based on current version
*/
export function suggestNextVersion(currentVersion: string): {
patch: string;
minor: string;
major: string;
} {
const v = parseVersion(currentVersion);
return {
patch: `${v.major}.${v.minor}.${v.patch + 1}`,
minor: `${v.major}.${v.minor + 1}.0`,
major: `${v.major + 1}.0.0`,
};
}
/**
* Validate that new version is a valid increment of current version
*/
export function validateVersionIncrement(
currentVersion: string,
newVersion: string,
): {
valid: boolean;
type: "patch" | "minor" | "major" | null;
error?: string;
} {
try {
const current = parseVersion(currentVersion);
const next = parseVersion(newVersion);
// Check if it's a valid increment
if (next.major === current.major + 1 && next.minor === 0 && next.patch === 0) {
return { valid: true, type: "major" };
}
if (next.major === current.major && next.minor === current.minor + 1 && next.patch === 0) {
return { valid: true, type: "minor" };
}
if (next.major === current.major && next.minor === current.minor && next.patch === current.patch + 1) {
return { valid: true, type: "patch" };
}
// Not a valid increment
const suggested = suggestNextVersion(currentVersion);
return {
valid: false,
type: null,
error: `Invalid version increment. Current: ${currentVersion}, Got: ${newVersion}. Valid options: ${suggested.patch} (patch), ${suggested.minor} (minor), ${suggested.major} (major)`,
};
} catch (e) {
return { valid: false, type: null, error: `Invalid version format: ${e}` };
}
}
/**
* Read version from contract file's @custom:version
*/
export function readContractVersion(contractPath: string): string | null {
try {
const content = fs.readFileSync(contractPath, "utf-8");
const match = content.match(/@custom:version\s+(\d+\.\d+\.\d+)/);
return match ? match[1] : null;
} catch {
return null;
}
}
/**
* Read reinitializer version from contract's initialize function
* Looks for patterns like: reinitializer(N) or initializer
* Returns the highest reinitializer version found, or 1 if only initializer is found
*/
export function readReinitializerVersion(contractPath: string): number | null {
try {
const content = fs.readFileSync(contractPath, "utf-8");
// Find all reinitializer(N) occurrences
const reinitMatches = content.matchAll(/reinitializer\s*\(\s*(\d+)\s*\)/g);
const versions: number[] = [];
for (const match of reinitMatches) {
versions.push(parseInt(match[1]));
}
if (versions.length > 0) {
// Return the highest version found
return Math.max(...versions);
}
// Check for basic initializer modifier (equivalent to reinitializer(1))
if (content.match(/\binitializer\b/) && !content.match(/reinitializer/)) {
return 1;
}
return null;
} catch {
return null;
}
}
/**
* Validate that reinitializer version matches expected version
* Expected version = previous initializer version + 1
*/
export function validateReinitializerVersion(
contractPath: string,
expectedVersion: number,
): { valid: boolean; actual: number | null; error?: string } {
const actual = readReinitializerVersion(contractPath);
if (actual === null) {
return {
valid: false,
actual: null,
error: "Could not find reinitializer/initializer modifier in contract",
};
}
if (actual !== expectedVersion) {
return {
valid: false,
actual,
error: `Reinitializer version mismatch. Expected: reinitializer(${expectedVersion}), Found: reinitializer(${actual})`,
};
}
return { valid: true, actual };
}
/**
* Update version in contract file's @custom:version
*/
export function updateContractVersion(contractPath: string, newVersion: string): boolean {
try {
let content = fs.readFileSync(contractPath, "utf-8");
const originalContent = content;
// Update @custom:version
content = content.replace(/@custom:version\s+\d+\.\d+\.\d+/, `@custom:version ${newVersion}`);
// Also update version() function if it exists
content = content.replace(/function version\(\)[^}]+return\s+"(\d+\.\d+\.\d+)"/, (match) =>
match.replace(/"\d+\.\d+\.\d+"/, `"${newVersion}"`),
);
if (content !== originalContent) {
fs.writeFileSync(contractPath, content);
return true;
}
return false;
} catch {
return false;
}
}
/**
* Get contract file path from contract ID
*/
export function getContractFilePath(contractId: string): string | null {
const contract = getContractDefinition(contractId);
const contractName = contract.source;
// Common paths to check
const possiblePaths = [
path.join(__dirname, `../../contracts/${contractName}.sol`),
path.join(__dirname, `../../contracts/tests/${contractName}.sol`),
path.join(__dirname, `../../contracts/registry/${contractName}.sol`),
path.join(__dirname, `../../contracts/utils/${contractName}.sol`),
path.join(__dirname, `../../contracts/sdk/${contractName}.sol`),
];
for (const p of possiblePaths) {
if (fs.existsSync(p)) {
return p;
}
}
return null;
}
/**
* Create a git commit
*/
export function gitCommit(message: string): boolean {
try {
execSync(`git add -A && git commit -m "${message}"`, { stdio: "pipe" });
return true;
} catch {
return false;
}
}
/**
* Format address for display (shortened)
*/
export function shortenAddress(address: string): string {
if (!address || address.length < 10) return address;
return `${address.slice(0, 6)}...${address.slice(-4)}`;
}
/**
* Get Safe API URL for a network
*/
export function getSafeApiUrl(network: SupportedNetwork): string {
// Safe Transaction Service API URLs (must end with /api/)
const urls: Record<SupportedNetwork, string> = {
celo: "https://safe-transaction-celo.safe.global/api/",
"celo-sepolia": "https://safe-transaction-celo.safe.global/api/", // Celo testnet uses same as mainnet
sepolia: "https://safe-transaction-sepolia.safe.global/api/",
localhost: "", // No Safe service for localhost
};
return urls[network];
}
/**
* Get block explorer URL for a network
*/
export function getExplorerUrl(network: SupportedNetwork): string {
const urls: Record<SupportedNetwork, string> = {
celo: "https://celoscan.io",
"celo-sepolia": "https://celo-sepolia.blockscout.com",
sepolia: "https://sepolia.etherscan.io",
localhost: "http://localhost:8545", // No explorer for localhost
};
return urls[network];
}
/**
* Get all contract IDs from registry
*/
export function getContractIds(): string[] {
const registry = readRegistry();
return Object.keys(registry.contracts);
}
/**
* Check if contract is deployed on network
*/
export function isDeployedOnNetwork(contractId: string, network: SupportedNetwork): boolean {
const deployment = getNetworkDeployment(contractId, network);
if (!deployment) return false;
const contract = getContractDefinition(contractId);
if (contract.type === "uups-proxy") {
return !!deployment.proxy;
}
return !!deployment.address;
}