diff --git a/contract_manager/scripts/check_proposal.ts b/contract_manager/scripts/check_proposal.ts index a80cdc1cc2..1704269885 100644 --- a/contract_manager/scripts/check_proposal.ts +++ b/contract_manager/scripts/check_proposal.ts @@ -12,6 +12,7 @@ import { EvmSetWormholeAddress, EvmUpgradeContract, getProposalInstructions, + MigrateGovernanceAndWormhole, MultisigParser, UpdateTrustedSigner256Bit, UpdateTrustedSigner264Bit, @@ -25,6 +26,7 @@ import Web3 from "web3"; import yargs from "yargs"; import { hideBin } from "yargs/helpers"; +import { getDefaultDeploymentConfig } from "../src/core/base"; import { CosmWasmChain, EvmChain, @@ -128,6 +130,128 @@ async function main() { } } } + if ( + instruction.governanceAction instanceof MigrateGovernanceAndWormhole + ) { + const action = instruction.governanceAction; + console.log( + `Verifying MigrateGovernanceAndWormhole on ${action.targetChainId}`, + ); + console.log(` wormhole address:\t${action.address}`); + console.log( + ` data sources:\t\t${JSON.stringify(action.dataSources)}`, + ); + console.log( + ` governance emitter:\t${JSON.stringify(action.governanceDataSource)}`, + ); + console.log( + ` governance index:\t${action.governanceDataSourceIndex}`, + ); + + const proProduction = getDefaultDeploymentConfig( + "pro-compatible-production", + ); + const proStaging = getDefaultDeploymentConfig( + "pro-compatible-staging", + ); + const matchesProductionSources = + JSON.stringify(action.dataSources) === + JSON.stringify(proProduction.dataSources); + const matchesStagingSources = + JSON.stringify(action.dataSources) === + JSON.stringify(proStaging.dataSources); + if (matchesProductionSources) { + console.log(" data sources match pro-compatible-production"); + } else if (matchesStagingSources) { + console.log(" data sources match pro-compatible-staging"); + } else { + console.log( + " WARNING: data sources do not match pro-compatible-production or pro-compatible-staging", + ); + console.log( + ` expected production:\t${JSON.stringify(proProduction.dataSources)}`, + ); + } + + const matchesProductionGov = + JSON.stringify(action.governanceDataSource) === + JSON.stringify(proProduction.governanceDataSource); + const matchesStagingGov = + JSON.stringify(action.governanceDataSource) === + JSON.stringify(proStaging.governanceDataSource); + if (matchesProductionGov) { + console.log( + " governance emitter matches pro-compatible-production default", + ); + } else if (matchesStagingGov) { + console.log( + " governance emitter matches pro-compatible-staging default", + ); + } else { + console.log( + " WARNING: governance emitter does not match pro-compatible-production or pro-compatible-staging defaults", + ); + console.log( + ` expected production:\t${JSON.stringify(proProduction.governanceDataSource)}`, + ); + console.log( + ` expected staging:\t${JSON.stringify(proStaging.governanceDataSource)}`, + ); + } + + for (const chain of Object.values(DefaultStore.chains)) { + if ( + !(chain instanceof EvmChain) || + chain.wormholeChainName !== action.targetChainId + ) { + continue; + } + if (chain.isMainnet() !== (cluster === "mainnet-beta")) { + continue; + } + + const expectedWormholes = Object.values( + DefaultStore.wormhole_contracts, + ).filter( + (c): c is EvmWormholeContract => + c instanceof EvmWormholeContract && + c.getChain().getId() === chain.getId() && + (c.deploymentType === "pro-compatible-production" || + c.deploymentType === "pro-compatible-staging"), + ); + + const normalizedActionAddress = action.address + .replace(/^0x/i, "") + .toLowerCase(); + const matching = expectedWormholes.find( + (c) => + c.address.replace(/^0x/i, "").toLowerCase() === + normalizedActionAddress, + ); + if (matching) { + console.log( + ` ${chain.getId()}: wormhole matches store entry ${matching.getId()} (${matching.deploymentType})`, + ); + } else if (expectedWormholes.length > 0) { + console.log( + ` ${chain.getId()}: WARNING wormhole ${action.address} not in store; known pro wormholes:`, + ); + for (const wh of expectedWormholes) { + console.log( + ` ${wh.address} (${wh.deploymentType})`, + ); + } + } else { + console.log( + ` ${chain.getId()}: no pro-compatible wormhole in store — verify manually: ${action.address}`, + ); + } + } + + console.log( + " NOTE: governanceDataSourceIndex must be strictly greater than the current on-chain index", + ); + } if (instruction.governanceAction instanceof EvmUpgradeContract) { console.log( `Verifying EVM Upgrade Contract on ${instruction.governanceAction.targetChainId}`, diff --git a/contract_manager/scripts/migrate_evm_pricefeed_to_pro.ts b/contract_manager/scripts/migrate_evm_pricefeed_to_pro.ts new file mode 100644 index 0000000000..d5a57813ae --- /dev/null +++ b/contract_manager/scripts/migrate_evm_pricefeed_to_pro.ts @@ -0,0 +1,258 @@ +/** biome-ignore-all lint/suspicious/noConsole: CLI script */ +/* eslint-disable @typescript-eslint/await-thenable */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable no-console */ +import { readFileSync } from "node:fs"; + +import yargs from "yargs"; +import { hideBin } from "yargs/helpers"; + +import type { DeploymentType } from "../src/core/base"; +import { + getDefaultDeploymentConfig, + toDeploymentType, + toPrivateKey, +} from "../src/core/base"; +import { EvmChain } from "../src/core/chains"; +import { EvmPriceFeedContract } from "../src/core/contracts"; +import { loadHotWallet } from "../src/node/utils/governance"; +import { DefaultStore } from "../src/node/utils/store"; +import { + COMMON_UPGRADE_OPTIONS, + getOrDeployWormholeContract, + getSelectedChains, + makeCacheFunction, +} from "./common"; + +const CACHE_FILE = ".cache-migrate-evm-pro"; +const runIfNotCached = makeCacheFunction(CACHE_FILE); + +const MAINNET_VAULT_ID = + "mainnet-beta_FVQyHcooAtThJ83XFrNnv74BcinbRH3bRmfFamAHBfuj"; +const DEVNET_VAULT_ID = + "devnet_6baWtW1zTUVMSJHJQVxDUXWzqrQeYBr6mu31j3bTKwY3"; + +/** + * Finds the legacy (stable/beta / unset) price feed proxy for a chain. + * Pro-compatible side-by-side deployments are skipped. + */ +function findLegacyPriceFeedContract( + chain: EvmChain, +): EvmPriceFeedContract | undefined { + for (const contract of Object.values(DefaultStore.contracts)) { + if (!(contract instanceof EvmPriceFeedContract)) continue; + if (contract.getChain().getId() !== chain.getId()) continue; + if ( + contract.deploymentType === undefined || + contract.deploymentType === "stable" || + contract.deploymentType === "beta" + ) { + return contract; + } + } + return undefined; +} + +function normalizeEmitterAddress(address: string): string { + const normalized = address.replace(/^0x/i, "").toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(normalized)) { + throw new Error( + `--governance-emitter-address must be a 32-byte hex string (64 hex chars), got: ${address}`, + ); + } + return normalized; +} + +const parser = yargs(hideBin(process.argv)) + .usage( + "Migrates legacy EVM Pyth price feed proxies in place to pro-compatible " + + "wormhole + data sources + governance emitter.\n" + + "Per chain: resolve/deploy pro wormhole, deploy new PythUpgradable impl, " + + "then propose UpgradeContract + MigrateGovernanceAndWormhole " + + "for the legacy proxy.\n" + + "Prerequisite: single-update fee must already be 0 via SetFee.\n" + + "MigrateGovernanceAndWormhole switches governance to the new emitter " + + "(index must be > current on-chain index) and resets the executed sequence.\n" + + `Uses a cache file (${CACHE_FILE}) to avoid deploying contracts twice.\n` + + "Usage: $0 --chain --chain --private-key " + + "--ops-key-path --std-output " + + "--std-output-dir " + + "--governance-emitter-chain " + + "--governance-emitter-address <32_byte_hex> " + + "--governance-data-source-index " + + "[--deployment-type pro-compatible-production] [--dry-run]", + ) + .options({ + ...COMMON_UPGRADE_OPTIONS, + "deployment-type": { + choices: ["pro-compatible-production", "pro-compatible-staging"] as const, + default: "pro-compatible-production" as const, + demandOption: false, + desc: "Pro-compatible deployment config for wormhole guardians and data sources", + type: "string", + }, + "dry-run": { + default: false, + desc: "Deploy contracts and build payloads but do not submit the vault proposal", + type: "boolean", + }, + "governance-data-source-index": { + demandOption: true, + desc: "New governance data source index (u32); must be strictly greater than the current on-chain index", + type: "number", + }, + "governance-emitter-address": { + demandOption: true, + desc: "New governance emitter address as 32-byte hex (with or without 0x)", + type: "string", + }, + "governance-emitter-chain": { + demandOption: true, + desc: "New governance emitter wormhole chain id", + type: "number", + }, + "std-output": { + demandOption: true, + desc: "Path to the standard JSON output of the PythUpgradable contract (forge artifact)", + type: "string", + }, + "std-output-dir": { + demandOption: true, + desc: "Path to the Foundry output directory used to deploy/reuse the pro-compatible wormhole receiver", + type: "string", + }, + }); + +async function main() { + const argv = await parser.argv; + const selectedChains = getSelectedChains(argv); + const deploymentType = toDeploymentType( + argv["deployment-type"], + ) as DeploymentType; + const { dataSources: proDataSources } = + getDefaultDeploymentConfig(deploymentType); + const governanceDataSource = { + emitterAddress: normalizeEmitterAddress(argv["governance-emitter-address"]), + emitterChain: argv["governance-emitter-chain"], + }; + const governanceDataSourceIndex = argv["governance-data-source-index"]; + if ( + !Number.isInteger(governanceDataSourceIndex) || + governanceDataSourceIndex < 0 || + governanceDataSourceIndex > 0xffff_ffff + ) { + throw new Error( + `--governance-data-source-index must be a u32 integer, got: ${governanceDataSourceIndex}`, + ); + } + const dryRun = argv["dry-run"]; + + const isMainnet = selectedChains[0]?.isMainnet() ?? false; + const vault = + DefaultStore.vaults[isMainnet ? MAINNET_VAULT_ID : DEVNET_VAULT_ID]; + + console.log("Using cache file", CACHE_FILE); + console.log("Deployment type", deploymentType); + console.log( + "Migrating legacy proxies on chains", + selectedChains.map((c) => c.getId()), + ); + console.log( + "New governance emitter", + `chain=${governanceDataSource.emitterChain}`, + `address=${governanceDataSource.emitterAddress}`, + `index=${governanceDataSourceIndex}`, + ); + if (dryRun) { + console.log("Dry run enabled — will not propose governance"); + } + + const wormholeDeployConfig = { + gasMultiplier: 2, + gasPriceMultiplier: 1, + jsonOutputDir: argv["std-output-dir"], + privateKey: toPrivateKey(argv["private-key"]), + saveContract: true, + type: deploymentType, + }; + + const payloads: Buffer[] = []; + for (const chain of selectedChains) { + const legacyContract = findLegacyPriceFeedContract(chain); + if (!legacyContract) { + console.warn( + `No legacy price feed contract found in store for ${chain.getId()}; ` + + `continuing (governance targets chain wormhole name ${chain.wormholeChainName})`, + ); + } else { + console.log( + `Legacy proxy on ${chain.getId()}: ${legacyContract.address}`, + ); + } + + console.log(`Resolving/deploying pro-compatible wormhole on ${chain.getId()}...`); + const proWormhole = await getOrDeployWormholeContract( + chain, + wormholeDeployConfig, + CACHE_FILE, + ); + console.log( + `Pro wormhole on ${chain.getId()}: ${proWormhole.address} (${proWormhole.deploymentType ?? "unknown"})`, + ); + + const artifact = JSON.parse(readFileSync(argv["std-output"], "utf8")); + console.log(`Deploying PythUpgradable implementation to ${chain.getId()}...`); + const implAddress = await runIfNotCached( + `deploy-impl-${chain.getId()}-${deploymentType}`, + () => { + return chain.deploy( + toPrivateKey(argv["private-key"]), + artifact.abi, + artifact.bytecode.object, + [], + ); + }, + ); + console.log( + `Deployed PythUpgradable impl at ${implAddress} on ${chain.getId()}`, + ); + + // Order matters: UpgradeContract must execute before migrate (action 10). + payloads.push( + chain.generateGovernanceUpgradePayload(implAddress.replace("0x", "")), + ); + payloads.push( + chain.generateGovernanceMigrateGovernanceAndWormholePayload( + proWormhole.address.replace("0x", ""), + proDataSources, + governanceDataSource, + governanceDataSourceIndex, + ), + ); + console.log( + `Queued UpgradeContract + MigrateGovernanceAndWormhole for ${chain.getId()}`, + ); + } + + console.log(`Built ${payloads.length} governance payloads (${payloads.length / 2} chains × 2)`); + console.log("Using vault", vault?.getId()); + + if (dryRun) { + console.log("Dry run complete — skipping proposeWormholeMessage"); + for (const [i, payload] of payloads.entries()) { + console.log(` payload[${i}] hex length=${payload.toString("hex").length / 2} bytes`); + } + return; + } + + const wallet = await loadHotWallet(argv["ops-key-path"]); + console.log("Using wallet", wallet.publicKey.toBase58()); + await vault?.connect(wallet); + const proposal = await vault?.proposeWormholeMessage(payloads); + console.log("Proposal address", proposal?.address.toBase58()); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises, unicorn/prefer-top-level-await +main(); diff --git a/contract_manager/src/core/chains.ts b/contract_manager/src/core/chains.ts index c6f41b23bd..9e5c098d10 100644 --- a/contract_manager/src/core/chains.ts +++ b/contract_manager/src/core/chains.ts @@ -47,6 +47,7 @@ import { EvmExecute, EvmSetWormholeAddress, EvmUpgradeContract, + MigrateGovernanceAndWormhole, SetDataSources, SetFee, SetValidPeriod, @@ -1911,6 +1912,31 @@ export class EvmChain extends Chain { return new EvmSetWormholeAddress(this.wormholeChainName, address).encode(); } + /** + * Returns the payload for a governance MigrateGovernanceAndWormhole + * instruction (action 10). Used for legacy → pro-compatible in-place migrate. + * Atomically sets wormhole, price data sources, and the new governance emitter + * (plus index / sequence reset). Fee must already be 0 via SetFee. + * @param address - hex string of the 20 byte wormhole receiver address without the 0x prefix + * @param dataSources - the new valid price data sources + * @param governanceDataSource - the new governance emitter + * @param governanceDataSourceIndex - u32 index; must be strictly greater than the current on-chain index + */ + generateGovernanceMigrateGovernanceAndWormholePayload( + address: string, + dataSources: DataSource[], + governanceDataSource: DataSource, + governanceDataSourceIndex: number, + ): Buffer { + return new MigrateGovernanceAndWormhole( + this.wormholeChainName, + address, + dataSources, + governanceDataSource, + governanceDataSourceIndex, + ).encode(); + } + toJson(): KeyValueConfig { return { id: this.id,