From a0a21657f408222f3989d3ae05739d241cc9dbcd Mon Sep 17 00:00:00 2001 From: keyvan Date: Tue, 28 Jul 2026 13:14:46 -0700 Subject: [PATCH 1/4] =?UTF-8?q?feat(contract=5Fmanager):=20add=20EVM=20leg?= =?UTF-8?q?acy=E2=86=92pro=20in-place=20migrate=20tooling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add SetWormholeAddressAndDataSources payload helper, migrate script, and proposal verification for upgrading legacy proxies onto pro-compatible wormhole + data sources without changing the consumer address. --- .../EVM_PRO_COMPATIBLE_IN_PLACE_UPGRADE.md | 239 ++++++++++++++++++ contract_manager/scripts/check_proposal.ts | 95 +++++++ .../scripts/migrate_evm_pricefeed_to_pro.ts | 206 +++++++++++++++ contract_manager/src/core/chains.ts | 24 ++ 4 files changed, 564 insertions(+) create mode 100644 contract_manager/EVM_PRO_COMPATIBLE_IN_PLACE_UPGRADE.md create mode 100644 contract_manager/scripts/migrate_evm_pricefeed_to_pro.ts diff --git a/contract_manager/EVM_PRO_COMPATIBLE_IN_PLACE_UPGRADE.md b/contract_manager/EVM_PRO_COMPATIBLE_IN_PLACE_UPGRADE.md new file mode 100644 index 0000000000..ac634451a4 --- /dev/null +++ b/contract_manager/EVM_PRO_COMPATIBLE_IN_PLACE_UPGRADE.md @@ -0,0 +1,239 @@ +# EVM legacy → pro-compatible in-place upgrade + +Plan for upgrading existing (legacy / `stable`) Pyth Core contracts **in place** so they accept Pyth Pro–signed payloads, while keeping the consumer-facing Pyth proxy address unchanged. + +This is the DAO “automatic upgrade” path described in the developer hub (as opposed to early adopters swapping to the side-by-side pro-compatible proxy address). + +## Goal + +| Keep | Change | +| --- | --- | +| Legacy Pyth **proxy** address (what consumers call) | Wormhole used for verification → pro-compatible receiver | +| Same Pyth ABI / feed IDs | Valid data sources → Pro emitter | +| | Quorum 2/3+1 → 1/2+1 (via the pro wormhole, not Pyth itself) | + +### What “Pro routers” means here + +**Pyth Pro routers are off-chain signers**, not contracts. Five routers produce and ECDSA-sign price Merkle roots (same signature scheme as Wormhole guardians). The on-chain Wormhole receiver only stores their **public keys** as its guardian set (`initialGuardianSet` in `getDefaultDeploymentConfig("pro-compatible-*")`) and enforces half quorum (3/5). Hermes gathers those signatures; the receiver verifies them. + +## Storage model (why a migrate action is needed) + +The consumer address is an ERC1967 **proxy**. Calls `delegatecall` into `PythUpgradable`; **all runtime state lives in the proxy**: + +| Location | What it holds | +| --- | --- | +| Pyth proxy (ERC1967 slot) | Implementation address | +| Pyth proxy `_state` | `wormhole` address, `validDataSources`, prices, fees, governance emitter, sequences, … | +| Wormhole receiver proxy | Guardian pubkeys, quorum logic (via `ReceiverImplementation` / `Half`) | + +Deploying a new wormhole receiver (with the correct guardian set) does **not** update the legacy Pyth proxy’s `_state.wormhole`. A bare `UpgradeContract` only swaps implementation bytecode; proxy storage is unchanged. + +`validDataSources` also differ in config: stable uses three Pythnet/Solana emitters; pro-compatible uses a single emitter (`PythnetPythnetPythnetPythnetPyth` on chain 26). `governanceDataSource` for `pro-compatible-production` matches `stable`. + +## Why not the obvious governance actions + +- **`SetWormholeAddress` exists** (action 6) but is insufficient for a Pro-guardian wormhole: the cutover VAA is verified against the *current* wormhole, then `setWormholeAddress` re-verifies the *same* VAA against the *new* wormhole. That requires overlapping guardian sets. +- **Guardian-set upgrade VAAs on the Wormhole receiver are not available** for moving to Pro routers (receiver governance only supports `submitNewGuardianSet` from Wormhole governance). +- **The legacy Wormhole receiver cannot be flipped to half-quorum in place**: quorum lives in `ReceiverImplementationHalf`, and `ReceiverGovernance` has no contract-upgrade path. +- **Migrate cannot run inside the first `UpgradeContract`**: that action executes on the *current* (legacy) implementation, which always calls `_upgradeToAndCallUUPS(impl, "", false)`. Atomic upgrade+migrate in one VAA is not possible without a prior impl that already knows how to pass migrate calldata. + +Therefore: leave the legacy wormhole unused, reuse (or deploy) a **pro-compatible wormhole receiver**, then on the **legacy Pyth proxy**: (1) `UpgradeContract` to a new impl that understands a new governance action, (2) that action writes wormhole + data sources + fee=`0` **without** dual-VAA verify. + +## Architecture (after cutover) + +```text +Consumer ──► legacy Pyth proxy (same address) + │ implementation: new PythUpgradable (adds migrate governance action) + │ storage.wormhole ──► pro-compatible WormholeReceiver + │ (ReceiverImplementationHalf + Pro router pubkeys) + │ storage.dataSources ──► Pro emitter + │ + └── (legacy WormholeReceiver left in place, unused) +``` + +## Agreed implementation: 1 impl deploy + 2 governance actions + +Per chain: + +1. **Deploy** one new `PythUpgradable` implementation (normal deploy tx; not a new proxy). +2. **Governance VAA₁ — `UpgradeContract`:** point the legacy proxy at that implementation. +3. **Governance VAA₂ — `SetWormholeAddressAndDataSources` (action 10):** set `_state.wormhole`, replace `validDataSources`, set single-update fee to `0`. + +Both VAAs still verify on the **legacy** wormhole (pointer unchanged until VAA₂). They can be one vault proposal with two wormhole messages; execute in order. After VAA₂, further governance must verify on the pro wormhole. + +### New governance action: `SetWormholeAddressAndDataSources` (action id **10**) + +Locked name/id for Solidity, `xc_admin_common`, and contract_manager. Fee is **required** in the payload (always set to `0`/`0` for this migration). + +Payload (fee is **required**, always set to `0` for this migration): + +| Field | Layout | Value | +| --- | --- | --- | +| `newWormholeAddress` | `address(20)` | pro-compatible receiver from step 1 below | +| `dataSources[]` | `num(u8) ‖ [chain(u16be) ‖ emitter(32)]*` | Pro emitter from `getDefaultDeploymentConfig("pro-compatible-production")` | +| `newFeeValue` / `newFeeExpo` | `u64be ‖ u64be` | `0` / `0` (fee = value × 10^expo) — **always included** | + +Full wire format (Target module): + +```text +PTGM(4) | module=1(1) | action=10(1) | targetChainId(u16be) | + newWormholeAddress(20) | + numSources(u8) | [emitterChain(u16be) | emitterAddress(32)]* | + newFeeValue(u64be) | newFeeExpo(u64be) +``` + +Behavior: + +- `setWormhole(newWormhole)` **without** the dual-VAA re-verify used by `SetWormholeAddress` +- Same data-source replacement logic as existing `setDataSources` +- Always set single-update fee to `0` (same logic as existing `setFee`) +- Emit existing `WormholeAddressSet` / `DataSourcesSet` / `FeeSet` +- Light sanity checks: non-zero address, `extcodesize > 0`, optionally `chainId()` doesn’t revert + +Governance is already trusted; the action need not be one-shot (unlike an earlier draft). Bundling wormhole + data sources + fee in **one** action avoids a half-migrated proxy. + +**Do not** change existing `SetWormholeAddress` (action 6) to drop dual-verify — keep that safety rail for normal use. + +## Steps (per chain) + +### 1. Ensure a pro-compatible Wormhole receiver exists + +- If the store already has a `pro-compatible-production` (or staging) wormhole for the chain → **reuse it**. +- Otherwise deploy a **full** receiver (not only `ReceiverImplementationHalf`): + - `ReceiverSetup` + - `ReceiverImplementationHalf` + - `WormholeReceiver` proxy, initialized with Pro router **public keys** as the guardian set and half quorum + - Same path as `deployWormholeContract` / `getOrDeployWormholeContract` with pro-compatible deployment config. + +### 2. Deploy a new `PythUpgradable` implementation + +- Implementation only (same pattern as `upgrade_evm_pricefeed_contracts.ts`). +- Do **not** deploy a new `ERC1967Proxy` for the in-place path. +- New code must parse/handle the migrate governance action (id 10). + +### 3. Propose governance (legacy proxy) + +- VAA₁: `UpgradeContract` → implementation from step 2. +- VAA₂: `SetWormholeAddressAndDataSources` with pro wormhole + Pro data sources + fee `0`. + +### 4. Execute VAAs in order on the legacy proxy + +- Verify post-state: `wormhole()`, `validDataSources()`, fee == `0`, and that a Pro Hermes update verifies. + +### 5. Hermes / ops cutover + +- On-chain migrate (VAA₂) must complete **before** `hermes.pyth.network` starts serving Pro payloads for that chain. +- Brief dual-fetch window for consumers who wait for automatic upgrade (documented in developer hub). + +## What we are not doing (this path) + +| Approach | Why not | +| --- | --- | +| Deploy a new Pyth **proxy** and tell consumers to swap | Early-upgrade path; new address | +| `SetWormholeAddress` (action 6) to the pro wormhole | Dual-verify against both guardian sets fails | +| Guardian-set VAA on legacy or pro wormhole | Cannot produce / not available for Pro routers | +| Upgrade legacy Wormhole receiver implementation | No governance upgrade path on receiver | +| Migrate via `upgradeToAndCall` in the first `UpgradeContract` | Legacy `upgradeUpgradableContract` always passes empty calldata | + +## How governance instructions are sent today + +Deploy (`deploy_evm_pricefeed_contracts.ts`) does **not** use governance — init sets wormhole / data sources / fees in one tx. Governance is only for post-deploy changes. + +```text +contract_manager script + → encode Buffer[] via @pythnetwork/xc-admin-common + → Vault.proposeWormholeMessage(payloads) + → Squads vault posts N Wormhole messages on Solana + → guardians attest → VAAs + → executeGovernanceInstruction(vaa) on each EVM chain +``` + +Closest patterns: + +| Script | What it proposes | +| --- | --- | +| `upgrade_evm_pricefeed_contracts.ts` | Deploy impl → N× `UpgradeContract` in one proposal | +| `batchDeployReceivers.ts` | Deploy receivers → N× `SetWormholeAddress` (action 6) — **not usable for Pro migrate** (dual-verify) | +| `generate_governance_set_fee_payload.ts` | N× `SetFee` | + +`proposeWormholeMessage(Buffer[])` already supports multiple different actions in one proposal. Order in the array becomes VAA sequence order → use `[UpgradeContract, Migrate]` per chain. + +Payload builders live on `EvmChain` (`generateGovernanceUpgradePayload`, `generateGovernanceSetWormholeAddressPayload`, …) and wrap `xc_admin_common` classes. Action IDs are in `TargetAction` (`PythGovernanceAction.ts`); next free id is **10**. + +## Code / tooling work + +### 1. Solidity + +- Add action 10 enum + payload parser in `PythGovernanceInstructions.sol` +- Handler in `PythGovernance.sol`: set wormhole (no dual-verify) + data sources + fee `0`; emit existing events +- Forge tests: happy path, rejects zero/empty code, data sources + fee updated, old `SetWormholeAddress` unchanged + +### 2. `xc_admin_common` (governance encoding) + +xc_admin does **not** talk to EVM RPCs for Target actions — it encodes/decodes payloads and proposes Wormhole messages from the Squads vault. + +Required changes: + +1. **`governance_payload/PythGovernanceAction.ts`** + - Add to `TargetAction`: `SetWormholeAddressAndDataSources: 10` + - Add `case 10` in `toActionName` +2. **New codec** `governance_payload/SetWormholeAddressAndDataSources.ts` + - Variable-length (like `SetDataSources`), not a fixed `layout` + - Constructor: `(targetChainId, address /* 20-byte hex without 0x */, dataSources, newFeeValue, newFeeExpo)` + - Body must match Solidity byte-for-byte: + ```text + newWormholeAddress(20) + || numSources(u8) || [chain(u16be)||emitter(32)]* + || newFeeValue(u64be) || newFeeExpo(u64be) // always present; migrate uses 0/0 + ``` +3. **`governance_payload/index.ts`** — import + `case` in `decodeGovernancePayload` + re-export +4. **Tests** — encode/decode roundtrip in `__tests__/GovernancePayload.test.ts` +5. **Optional UX** — `xc_admin_frontend` proposal summary so action 10 is not `"unknown"` + +No changes needed to crank / vault multi-message machinery, or to action 6. + +### 3. Contract manager + +1. **`EvmChain.generateGovernanceSetWormholeAddressAndDataSourcesPayload(address, dataSources, feeValue, feeExpo)`** wrapping the new codec (fee args always passed; migrate script hardcodes `0n`/`0n`) +2. **Script** `migrate_evm_pricefeed_to_pro.ts`, modeled on upgrade + `batchDeployReceivers`: + - resolve or deploy pro wormhole via `getOrDeployWormholeContract` + - deploy new Pyth implementation + - build `[UpgradeContract, SetWormholeAddressAndDataSources]` payloads for the **legacy** proxy + - `vault.proposeWormholeMessage(payloads)` +3. **`check_proposal.ts`** — assert upgrade target and migrate targets (pro wormhole, Pro data sources, fee `0`) +4. **Store** — after execution, note that the legacy proxy entry is now pro-compatible; keep historical wormhole pointer clear for ops + +Example proposal shape: + +```ts +payloads.push(chain.generateGovernanceUpgradePayload(newImpl)); +payloads.push( + chain.generateGovernanceSetWormholeAddressAndDataSourcesPayload( + proReceiver.replace("0x", ""), + proDataSources, + 0n, + 0n, + ), +); +await vault.proposeWormholeMessage(payloads); +``` + +## Suggested rollout order + +1. Implement + forge-test the new action and upgrade→migrate sequence on a single testnet. +2. Dry-run script: deploy/reuse wormhole → deploy impl → propose both VAAs → execute → verify Pro update. +3. Mainnet chain batch(es) with proposal checklist and post-execute verification. +4. Coordinate Hermes redirect with on-chain completion. + +## Related files + +- `contract_manager/src/core/base.ts` — `DeploymentType`, `getDefaultDeploymentConfig` +- `contract_manager/scripts/common.ts` — `getOrDeployWormholeContract`, `deployWormholeContract` +- `contract_manager/scripts/deploy_evm_pricefeed_contracts.ts` — full proxy deploy (side-by-side) +- `contract_manager/scripts/upgrade_evm_pricefeed_contracts.ts` — impl deploy + `UpgradeContract` (no migrate today) +- `target_chains/ethereum/contracts/contracts/pyth/PythUpgradable.sol` +- `target_chains/ethereum/contracts/contracts/pyth/PythGovernance.sol` — `SetWormholeAddress` dual-verify +- `target_chains/ethereum/contracts/contracts/pyth/PythState.sol` — proxy storage layout +- `target_chains/ethereum/contracts/contracts/wormhole-receiver/ReceiverImplementationHalf.sol` +- `governance/xc_admin/packages/xc_admin_common/src/governance_payload/` — action codecs +- `apps/developer-hub/content/docs/price-feeds/core/upgrade/` — consumer-facing docs diff --git a/contract_manager/scripts/check_proposal.ts b/contract_manager/scripts/check_proposal.ts index a80cdc1cc2..1484b426e1 100644 --- a/contract_manager/scripts/check_proposal.ts +++ b/contract_manager/scripts/check_proposal.ts @@ -13,6 +13,7 @@ import { EvmUpgradeContract, getProposalInstructions, MultisigParser, + SetWormholeAddressAndDataSources, UpdateTrustedSigner256Bit, UpdateTrustedSigner264Bit, UpgradeSuiLazerContract, @@ -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,99 @@ async function main() { } } } + if ( + instruction.governanceAction instanceof SetWormholeAddressAndDataSources + ) { + const action = instruction.governanceAction; + console.log( + `Verifying SetWormholeAddressAndDataSources on ${action.targetChainId}`, + ); + console.log(` wormhole address:\t${action.address}`); + console.log( + ` data sources:\t\t${JSON.stringify(action.dataSources)}`, + ); + console.log( + ` fee value/expo:\t${action.newFeeValue} / ${action.newFeeExpo}`, + ); + + if (action.newFeeValue !== 0n || action.newFeeExpo !== 0n) { + console.log( + ` WARNING: expected fee 0/0 for pro migrate, got ${action.newFeeValue}/${action.newFeeExpo}`, + ); + } + + const proProductionSources = + getDefaultDeploymentConfig("pro-compatible-production").dataSources; + const proStagingSources = + getDefaultDeploymentConfig("pro-compatible-staging").dataSources; + const matchesProduction = + JSON.stringify(action.dataSources) === + JSON.stringify(proProductionSources); + const matchesStaging = + JSON.stringify(action.dataSources) === + JSON.stringify(proStagingSources); + if (matchesProduction) { + console.log(" data sources match pro-compatible-production"); + } else if (matchesStaging) { + 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(proProductionSources)}`, + ); + } + + 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}`, + ); + } + } + } 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..5434e6b170 --- /dev/null +++ b/contract_manager/scripts/migrate_evm_pricefeed_to_pro.ts @@ -0,0 +1,206 @@ +/** 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; +} + +const parser = yargs(hideBin(process.argv)) + .usage( + "Migrates legacy EVM Pyth price feed proxies in place to pro-compatible " + + "wormhole + data sources.\n" + + "Per chain: resolve/deploy pro wormhole, deploy new PythUpgradable impl, " + + "then propose UpgradeContract + SetWormholeAddressAndDataSources " + + "(fee 0/0) for the legacy proxy.\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 [--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", + }, + "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 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()), + ); + 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.generateGovernanceSetWormholeAddressAndDataSourcesPayload( + proWormhole.address.replace("0x", ""), + proDataSources, + 0n, + 0n, + ), + ); + console.log( + `Queued UpgradeContract + SetWormholeAddressAndDataSources 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..a5ee0c2cad 100644 --- a/contract_manager/src/core/chains.ts +++ b/contract_manager/src/core/chains.ts @@ -50,6 +50,7 @@ import { SetDataSources, SetFee, SetValidPeriod, + SetWormholeAddressAndDataSources, toChainId, UpdateTrustedSigner264Bit, UpgradeContract256Bit, @@ -1911,6 +1912,29 @@ export class EvmChain extends Chain { return new EvmSetWormholeAddress(this.wormholeChainName, address).encode(); } + /** + * Returns the payload for a governance SetWormholeAddressAndDataSources + * instruction (action 10). Used for legacy → pro-compatible in-place migrate. + * @param address - hex string of the 20 byte wormhole receiver address without the 0x prefix + * @param dataSources - the new valid data sources + * @param feeValue - single-update fee value (migrate uses 0) + * @param feeExpo - single-update fee exponent (migrate uses 0) + */ + generateGovernanceSetWormholeAddressAndDataSourcesPayload( + address: string, + dataSources: DataSource[], + feeValue: bigint, + feeExpo: bigint, + ): Buffer { + return new SetWormholeAddressAndDataSources( + this.wormholeChainName, + address, + dataSources, + feeValue, + feeExpo, + ).encode(); + } + toJson(): KeyValueConfig { return { id: this.id, From e13fab4c1e8d84c423c572ea6a19f28c86f4595f Mon Sep 17 00:00:00 2001 From: keyvan Date: Tue, 28 Jul 2026 13:22:48 -0700 Subject: [PATCH 2/4] chore(contract_manager): drop in-place upgrade plan markdown from PR Keep the migrate tooling PR scoped to code changes only. --- .../EVM_PRO_COMPATIBLE_IN_PLACE_UPGRADE.md | 239 ------------------ 1 file changed, 239 deletions(-) delete mode 100644 contract_manager/EVM_PRO_COMPATIBLE_IN_PLACE_UPGRADE.md diff --git a/contract_manager/EVM_PRO_COMPATIBLE_IN_PLACE_UPGRADE.md b/contract_manager/EVM_PRO_COMPATIBLE_IN_PLACE_UPGRADE.md deleted file mode 100644 index ac634451a4..0000000000 --- a/contract_manager/EVM_PRO_COMPATIBLE_IN_PLACE_UPGRADE.md +++ /dev/null @@ -1,239 +0,0 @@ -# EVM legacy → pro-compatible in-place upgrade - -Plan for upgrading existing (legacy / `stable`) Pyth Core contracts **in place** so they accept Pyth Pro–signed payloads, while keeping the consumer-facing Pyth proxy address unchanged. - -This is the DAO “automatic upgrade” path described in the developer hub (as opposed to early adopters swapping to the side-by-side pro-compatible proxy address). - -## Goal - -| Keep | Change | -| --- | --- | -| Legacy Pyth **proxy** address (what consumers call) | Wormhole used for verification → pro-compatible receiver | -| Same Pyth ABI / feed IDs | Valid data sources → Pro emitter | -| | Quorum 2/3+1 → 1/2+1 (via the pro wormhole, not Pyth itself) | - -### What “Pro routers” means here - -**Pyth Pro routers are off-chain signers**, not contracts. Five routers produce and ECDSA-sign price Merkle roots (same signature scheme as Wormhole guardians). The on-chain Wormhole receiver only stores their **public keys** as its guardian set (`initialGuardianSet` in `getDefaultDeploymentConfig("pro-compatible-*")`) and enforces half quorum (3/5). Hermes gathers those signatures; the receiver verifies them. - -## Storage model (why a migrate action is needed) - -The consumer address is an ERC1967 **proxy**. Calls `delegatecall` into `PythUpgradable`; **all runtime state lives in the proxy**: - -| Location | What it holds | -| --- | --- | -| Pyth proxy (ERC1967 slot) | Implementation address | -| Pyth proxy `_state` | `wormhole` address, `validDataSources`, prices, fees, governance emitter, sequences, … | -| Wormhole receiver proxy | Guardian pubkeys, quorum logic (via `ReceiverImplementation` / `Half`) | - -Deploying a new wormhole receiver (with the correct guardian set) does **not** update the legacy Pyth proxy’s `_state.wormhole`. A bare `UpgradeContract` only swaps implementation bytecode; proxy storage is unchanged. - -`validDataSources` also differ in config: stable uses three Pythnet/Solana emitters; pro-compatible uses a single emitter (`PythnetPythnetPythnetPythnetPyth` on chain 26). `governanceDataSource` for `pro-compatible-production` matches `stable`. - -## Why not the obvious governance actions - -- **`SetWormholeAddress` exists** (action 6) but is insufficient for a Pro-guardian wormhole: the cutover VAA is verified against the *current* wormhole, then `setWormholeAddress` re-verifies the *same* VAA against the *new* wormhole. That requires overlapping guardian sets. -- **Guardian-set upgrade VAAs on the Wormhole receiver are not available** for moving to Pro routers (receiver governance only supports `submitNewGuardianSet` from Wormhole governance). -- **The legacy Wormhole receiver cannot be flipped to half-quorum in place**: quorum lives in `ReceiverImplementationHalf`, and `ReceiverGovernance` has no contract-upgrade path. -- **Migrate cannot run inside the first `UpgradeContract`**: that action executes on the *current* (legacy) implementation, which always calls `_upgradeToAndCallUUPS(impl, "", false)`. Atomic upgrade+migrate in one VAA is not possible without a prior impl that already knows how to pass migrate calldata. - -Therefore: leave the legacy wormhole unused, reuse (or deploy) a **pro-compatible wormhole receiver**, then on the **legacy Pyth proxy**: (1) `UpgradeContract` to a new impl that understands a new governance action, (2) that action writes wormhole + data sources + fee=`0` **without** dual-VAA verify. - -## Architecture (after cutover) - -```text -Consumer ──► legacy Pyth proxy (same address) - │ implementation: new PythUpgradable (adds migrate governance action) - │ storage.wormhole ──► pro-compatible WormholeReceiver - │ (ReceiverImplementationHalf + Pro router pubkeys) - │ storage.dataSources ──► Pro emitter - │ - └── (legacy WormholeReceiver left in place, unused) -``` - -## Agreed implementation: 1 impl deploy + 2 governance actions - -Per chain: - -1. **Deploy** one new `PythUpgradable` implementation (normal deploy tx; not a new proxy). -2. **Governance VAA₁ — `UpgradeContract`:** point the legacy proxy at that implementation. -3. **Governance VAA₂ — `SetWormholeAddressAndDataSources` (action 10):** set `_state.wormhole`, replace `validDataSources`, set single-update fee to `0`. - -Both VAAs still verify on the **legacy** wormhole (pointer unchanged until VAA₂). They can be one vault proposal with two wormhole messages; execute in order. After VAA₂, further governance must verify on the pro wormhole. - -### New governance action: `SetWormholeAddressAndDataSources` (action id **10**) - -Locked name/id for Solidity, `xc_admin_common`, and contract_manager. Fee is **required** in the payload (always set to `0`/`0` for this migration). - -Payload (fee is **required**, always set to `0` for this migration): - -| Field | Layout | Value | -| --- | --- | --- | -| `newWormholeAddress` | `address(20)` | pro-compatible receiver from step 1 below | -| `dataSources[]` | `num(u8) ‖ [chain(u16be) ‖ emitter(32)]*` | Pro emitter from `getDefaultDeploymentConfig("pro-compatible-production")` | -| `newFeeValue` / `newFeeExpo` | `u64be ‖ u64be` | `0` / `0` (fee = value × 10^expo) — **always included** | - -Full wire format (Target module): - -```text -PTGM(4) | module=1(1) | action=10(1) | targetChainId(u16be) | - newWormholeAddress(20) | - numSources(u8) | [emitterChain(u16be) | emitterAddress(32)]* | - newFeeValue(u64be) | newFeeExpo(u64be) -``` - -Behavior: - -- `setWormhole(newWormhole)` **without** the dual-VAA re-verify used by `SetWormholeAddress` -- Same data-source replacement logic as existing `setDataSources` -- Always set single-update fee to `0` (same logic as existing `setFee`) -- Emit existing `WormholeAddressSet` / `DataSourcesSet` / `FeeSet` -- Light sanity checks: non-zero address, `extcodesize > 0`, optionally `chainId()` doesn’t revert - -Governance is already trusted; the action need not be one-shot (unlike an earlier draft). Bundling wormhole + data sources + fee in **one** action avoids a half-migrated proxy. - -**Do not** change existing `SetWormholeAddress` (action 6) to drop dual-verify — keep that safety rail for normal use. - -## Steps (per chain) - -### 1. Ensure a pro-compatible Wormhole receiver exists - -- If the store already has a `pro-compatible-production` (or staging) wormhole for the chain → **reuse it**. -- Otherwise deploy a **full** receiver (not only `ReceiverImplementationHalf`): - - `ReceiverSetup` - - `ReceiverImplementationHalf` - - `WormholeReceiver` proxy, initialized with Pro router **public keys** as the guardian set and half quorum - - Same path as `deployWormholeContract` / `getOrDeployWormholeContract` with pro-compatible deployment config. - -### 2. Deploy a new `PythUpgradable` implementation - -- Implementation only (same pattern as `upgrade_evm_pricefeed_contracts.ts`). -- Do **not** deploy a new `ERC1967Proxy` for the in-place path. -- New code must parse/handle the migrate governance action (id 10). - -### 3. Propose governance (legacy proxy) - -- VAA₁: `UpgradeContract` → implementation from step 2. -- VAA₂: `SetWormholeAddressAndDataSources` with pro wormhole + Pro data sources + fee `0`. - -### 4. Execute VAAs in order on the legacy proxy - -- Verify post-state: `wormhole()`, `validDataSources()`, fee == `0`, and that a Pro Hermes update verifies. - -### 5. Hermes / ops cutover - -- On-chain migrate (VAA₂) must complete **before** `hermes.pyth.network` starts serving Pro payloads for that chain. -- Brief dual-fetch window for consumers who wait for automatic upgrade (documented in developer hub). - -## What we are not doing (this path) - -| Approach | Why not | -| --- | --- | -| Deploy a new Pyth **proxy** and tell consumers to swap | Early-upgrade path; new address | -| `SetWormholeAddress` (action 6) to the pro wormhole | Dual-verify against both guardian sets fails | -| Guardian-set VAA on legacy or pro wormhole | Cannot produce / not available for Pro routers | -| Upgrade legacy Wormhole receiver implementation | No governance upgrade path on receiver | -| Migrate via `upgradeToAndCall` in the first `UpgradeContract` | Legacy `upgradeUpgradableContract` always passes empty calldata | - -## How governance instructions are sent today - -Deploy (`deploy_evm_pricefeed_contracts.ts`) does **not** use governance — init sets wormhole / data sources / fees in one tx. Governance is only for post-deploy changes. - -```text -contract_manager script - → encode Buffer[] via @pythnetwork/xc-admin-common - → Vault.proposeWormholeMessage(payloads) - → Squads vault posts N Wormhole messages on Solana - → guardians attest → VAAs - → executeGovernanceInstruction(vaa) on each EVM chain -``` - -Closest patterns: - -| Script | What it proposes | -| --- | --- | -| `upgrade_evm_pricefeed_contracts.ts` | Deploy impl → N× `UpgradeContract` in one proposal | -| `batchDeployReceivers.ts` | Deploy receivers → N× `SetWormholeAddress` (action 6) — **not usable for Pro migrate** (dual-verify) | -| `generate_governance_set_fee_payload.ts` | N× `SetFee` | - -`proposeWormholeMessage(Buffer[])` already supports multiple different actions in one proposal. Order in the array becomes VAA sequence order → use `[UpgradeContract, Migrate]` per chain. - -Payload builders live on `EvmChain` (`generateGovernanceUpgradePayload`, `generateGovernanceSetWormholeAddressPayload`, …) and wrap `xc_admin_common` classes. Action IDs are in `TargetAction` (`PythGovernanceAction.ts`); next free id is **10**. - -## Code / tooling work - -### 1. Solidity - -- Add action 10 enum + payload parser in `PythGovernanceInstructions.sol` -- Handler in `PythGovernance.sol`: set wormhole (no dual-verify) + data sources + fee `0`; emit existing events -- Forge tests: happy path, rejects zero/empty code, data sources + fee updated, old `SetWormholeAddress` unchanged - -### 2. `xc_admin_common` (governance encoding) - -xc_admin does **not** talk to EVM RPCs for Target actions — it encodes/decodes payloads and proposes Wormhole messages from the Squads vault. - -Required changes: - -1. **`governance_payload/PythGovernanceAction.ts`** - - Add to `TargetAction`: `SetWormholeAddressAndDataSources: 10` - - Add `case 10` in `toActionName` -2. **New codec** `governance_payload/SetWormholeAddressAndDataSources.ts` - - Variable-length (like `SetDataSources`), not a fixed `layout` - - Constructor: `(targetChainId, address /* 20-byte hex without 0x */, dataSources, newFeeValue, newFeeExpo)` - - Body must match Solidity byte-for-byte: - ```text - newWormholeAddress(20) - || numSources(u8) || [chain(u16be)||emitter(32)]* - || newFeeValue(u64be) || newFeeExpo(u64be) // always present; migrate uses 0/0 - ``` -3. **`governance_payload/index.ts`** — import + `case` in `decodeGovernancePayload` + re-export -4. **Tests** — encode/decode roundtrip in `__tests__/GovernancePayload.test.ts` -5. **Optional UX** — `xc_admin_frontend` proposal summary so action 10 is not `"unknown"` - -No changes needed to crank / vault multi-message machinery, or to action 6. - -### 3. Contract manager - -1. **`EvmChain.generateGovernanceSetWormholeAddressAndDataSourcesPayload(address, dataSources, feeValue, feeExpo)`** wrapping the new codec (fee args always passed; migrate script hardcodes `0n`/`0n`) -2. **Script** `migrate_evm_pricefeed_to_pro.ts`, modeled on upgrade + `batchDeployReceivers`: - - resolve or deploy pro wormhole via `getOrDeployWormholeContract` - - deploy new Pyth implementation - - build `[UpgradeContract, SetWormholeAddressAndDataSources]` payloads for the **legacy** proxy - - `vault.proposeWormholeMessage(payloads)` -3. **`check_proposal.ts`** — assert upgrade target and migrate targets (pro wormhole, Pro data sources, fee `0`) -4. **Store** — after execution, note that the legacy proxy entry is now pro-compatible; keep historical wormhole pointer clear for ops - -Example proposal shape: - -```ts -payloads.push(chain.generateGovernanceUpgradePayload(newImpl)); -payloads.push( - chain.generateGovernanceSetWormholeAddressAndDataSourcesPayload( - proReceiver.replace("0x", ""), - proDataSources, - 0n, - 0n, - ), -); -await vault.proposeWormholeMessage(payloads); -``` - -## Suggested rollout order - -1. Implement + forge-test the new action and upgrade→migrate sequence on a single testnet. -2. Dry-run script: deploy/reuse wormhole → deploy impl → propose both VAAs → execute → verify Pro update. -3. Mainnet chain batch(es) with proposal checklist and post-execute verification. -4. Coordinate Hermes redirect with on-chain completion. - -## Related files - -- `contract_manager/src/core/base.ts` — `DeploymentType`, `getDefaultDeploymentConfig` -- `contract_manager/scripts/common.ts` — `getOrDeployWormholeContract`, `deployWormholeContract` -- `contract_manager/scripts/deploy_evm_pricefeed_contracts.ts` — full proxy deploy (side-by-side) -- `contract_manager/scripts/upgrade_evm_pricefeed_contracts.ts` — impl deploy + `UpgradeContract` (no migrate today) -- `target_chains/ethereum/contracts/contracts/pyth/PythUpgradable.sol` -- `target_chains/ethereum/contracts/contracts/pyth/PythGovernance.sol` — `SetWormholeAddress` dual-verify -- `target_chains/ethereum/contracts/contracts/pyth/PythState.sol` — proxy storage layout -- `target_chains/ethereum/contracts/contracts/wormhole-receiver/ReceiverImplementationHalf.sol` -- `governance/xc_admin/packages/xc_admin_common/src/governance_payload/` — action codecs -- `apps/developer-hub/content/docs/price-feeds/core/upgrade/` — consumer-facing docs From e0e82a089470d2e7c28d3b87ee91c2d9f4defdc2 Mon Sep 17 00:00:00 2001 From: keyvan Date: Wed, 29 Jul 2026 15:59:26 -0700 Subject: [PATCH 3/4] refactor(contract_manager): drop fee args from pro migrate tooling Assumes fee was already set to 0 via SetFee before migrate. --- contract_manager/scripts/check_proposal.ts | 9 --------- contract_manager/scripts/migrate_evm_pricefeed_to_pro.ts | 5 ++--- contract_manager/src/core/chains.ts | 7 +------ 3 files changed, 3 insertions(+), 18 deletions(-) diff --git a/contract_manager/scripts/check_proposal.ts b/contract_manager/scripts/check_proposal.ts index 1484b426e1..f0f8b672e2 100644 --- a/contract_manager/scripts/check_proposal.ts +++ b/contract_manager/scripts/check_proposal.ts @@ -141,15 +141,6 @@ async function main() { console.log( ` data sources:\t\t${JSON.stringify(action.dataSources)}`, ); - console.log( - ` fee value/expo:\t${action.newFeeValue} / ${action.newFeeExpo}`, - ); - - if (action.newFeeValue !== 0n || action.newFeeExpo !== 0n) { - console.log( - ` WARNING: expected fee 0/0 for pro migrate, got ${action.newFeeValue}/${action.newFeeExpo}`, - ); - } const proProductionSources = getDefaultDeploymentConfig("pro-compatible-production").dataSources; diff --git a/contract_manager/scripts/migrate_evm_pricefeed_to_pro.ts b/contract_manager/scripts/migrate_evm_pricefeed_to_pro.ts index 5434e6b170..f923c02137 100644 --- a/contract_manager/scripts/migrate_evm_pricefeed_to_pro.ts +++ b/contract_manager/scripts/migrate_evm_pricefeed_to_pro.ts @@ -61,7 +61,8 @@ const parser = yargs(hideBin(process.argv)) "wormhole + data sources.\n" + "Per chain: resolve/deploy pro wormhole, deploy new PythUpgradable impl, " + "then propose UpgradeContract + SetWormholeAddressAndDataSources " + - "(fee 0/0) for the legacy proxy.\n" + + "for the legacy proxy.\n" + + "Assumes single-update fee was already set to 0 via SetFee beforehand.\n" + `Uses a cache file (${CACHE_FILE}) to avoid deploying contracts twice.\n` + "Usage: $0 --chain --chain --private-key " + "--ops-key-path --std-output " + @@ -175,8 +176,6 @@ async function main() { chain.generateGovernanceSetWormholeAddressAndDataSourcesPayload( proWormhole.address.replace("0x", ""), proDataSources, - 0n, - 0n, ), ); console.log( diff --git a/contract_manager/src/core/chains.ts b/contract_manager/src/core/chains.ts index a5ee0c2cad..b3056a433b 100644 --- a/contract_manager/src/core/chains.ts +++ b/contract_manager/src/core/chains.ts @@ -1915,23 +1915,18 @@ export class EvmChain extends Chain { /** * Returns the payload for a governance SetWormholeAddressAndDataSources * instruction (action 10). Used for legacy → pro-compatible in-place migrate. + * Fee is set separately via SetFee before migration. * @param address - hex string of the 20 byte wormhole receiver address without the 0x prefix * @param dataSources - the new valid data sources - * @param feeValue - single-update fee value (migrate uses 0) - * @param feeExpo - single-update fee exponent (migrate uses 0) */ generateGovernanceSetWormholeAddressAndDataSourcesPayload( address: string, dataSources: DataSource[], - feeValue: bigint, - feeExpo: bigint, ): Buffer { return new SetWormholeAddressAndDataSources( this.wormholeChainName, address, dataSources, - feeValue, - feeExpo, ).encode(); } From e5d60a180b8df7080a8c40fb515bf9d9e218fdba Mon Sep 17 00:00:00 2001 From: keyvan Date: Thu, 30 Jul 2026 13:37:11 -0700 Subject: [PATCH 4/4] refactor(contract_manager): rename migrate tooling to MigrateGovernanceAndWormhole Expand action 10 payloads with explicit governance emitter and index CLI args. --- contract_manager/scripts/check_proposal.ts | 66 +++++++++++++++---- .../scripts/migrate_evm_pricefeed_to_pro.ts | 65 ++++++++++++++++-- contract_manager/src/core/chains.ts | 19 ++++-- 3 files changed, 124 insertions(+), 26 deletions(-) diff --git a/contract_manager/scripts/check_proposal.ts b/contract_manager/scripts/check_proposal.ts index f0f8b672e2..1704269885 100644 --- a/contract_manager/scripts/check_proposal.ts +++ b/contract_manager/scripts/check_proposal.ts @@ -12,8 +12,8 @@ import { EvmSetWormholeAddress, EvmUpgradeContract, getProposalInstructions, + MigrateGovernanceAndWormhole, MultisigParser, - SetWormholeAddressAndDataSources, UpdateTrustedSigner256Bit, UpdateTrustedSigner264Bit, UpgradeSuiLazerContract, @@ -131,37 +131,71 @@ async function main() { } } if ( - instruction.governanceAction instanceof SetWormholeAddressAndDataSources + instruction.governanceAction instanceof MigrateGovernanceAndWormhole ) { const action = instruction.governanceAction; console.log( - `Verifying SetWormholeAddressAndDataSources on ${action.targetChainId}`, + `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 proProductionSources = - getDefaultDeploymentConfig("pro-compatible-production").dataSources; - const proStagingSources = - getDefaultDeploymentConfig("pro-compatible-staging").dataSources; - const matchesProduction = + const proProduction = getDefaultDeploymentConfig( + "pro-compatible-production", + ); + const proStaging = getDefaultDeploymentConfig( + "pro-compatible-staging", + ); + const matchesProductionSources = JSON.stringify(action.dataSources) === - JSON.stringify(proProductionSources); - const matchesStaging = + JSON.stringify(proProduction.dataSources); + const matchesStagingSources = JSON.stringify(action.dataSources) === - JSON.stringify(proStagingSources); - if (matchesProduction) { + JSON.stringify(proStaging.dataSources); + if (matchesProductionSources) { console.log(" data sources match pro-compatible-production"); - } else if (matchesStaging) { + } 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(proProductionSources)}`, + ` 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)}`, ); } @@ -213,6 +247,10 @@ async function main() { ); } } + + console.log( + " NOTE: governanceDataSourceIndex must be strictly greater than the current on-chain index", + ); } if (instruction.governanceAction instanceof EvmUpgradeContract) { console.log( diff --git a/contract_manager/scripts/migrate_evm_pricefeed_to_pro.ts b/contract_manager/scripts/migrate_evm_pricefeed_to_pro.ts index f923c02137..d5a57813ae 100644 --- a/contract_manager/scripts/migrate_evm_pricefeed_to_pro.ts +++ b/contract_manager/scripts/migrate_evm_pricefeed_to_pro.ts @@ -55,18 +55,34 @@ function findLegacyPriceFeedContract( 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.\n" + + "wormhole + data sources + governance emitter.\n" + "Per chain: resolve/deploy pro wormhole, deploy new PythUpgradable impl, " + - "then propose UpgradeContract + SetWormholeAddressAndDataSources " + + "then propose UpgradeContract + MigrateGovernanceAndWormhole " + "for the legacy proxy.\n" + - "Assumes single-update fee was already set to 0 via SetFee beforehand.\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 [--deployment-type pro-compatible-production] [--dry-run]", + "--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, @@ -82,6 +98,21 @@ const parser = yargs(hideBin(process.argv)) 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)", @@ -102,6 +133,20 @@ async function main() { ) 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; @@ -114,6 +159,12 @@ async function main() { "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"); } @@ -173,13 +224,15 @@ async function main() { chain.generateGovernanceUpgradePayload(implAddress.replace("0x", "")), ); payloads.push( - chain.generateGovernanceSetWormholeAddressAndDataSourcesPayload( + chain.generateGovernanceMigrateGovernanceAndWormholePayload( proWormhole.address.replace("0x", ""), proDataSources, + governanceDataSource, + governanceDataSourceIndex, ), ); console.log( - `Queued UpgradeContract + SetWormholeAddressAndDataSources for ${chain.getId()}`, + `Queued UpgradeContract + MigrateGovernanceAndWormhole for ${chain.getId()}`, ); } diff --git a/contract_manager/src/core/chains.ts b/contract_manager/src/core/chains.ts index b3056a433b..9e5c098d10 100644 --- a/contract_manager/src/core/chains.ts +++ b/contract_manager/src/core/chains.ts @@ -47,10 +47,10 @@ import { EvmExecute, EvmSetWormholeAddress, EvmUpgradeContract, + MigrateGovernanceAndWormhole, SetDataSources, SetFee, SetValidPeriod, - SetWormholeAddressAndDataSources, toChainId, UpdateTrustedSigner264Bit, UpgradeContract256Bit, @@ -1913,20 +1913,27 @@ export class EvmChain extends Chain { } /** - * Returns the payload for a governance SetWormholeAddressAndDataSources + * Returns the payload for a governance MigrateGovernanceAndWormhole * instruction (action 10). Used for legacy → pro-compatible in-place migrate. - * Fee is set separately via SetFee before migration. + * 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 data sources + * @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 */ - generateGovernanceSetWormholeAddressAndDataSourcesPayload( + generateGovernanceMigrateGovernanceAndWormholePayload( address: string, dataSources: DataSource[], + governanceDataSource: DataSource, + governanceDataSourceIndex: number, ): Buffer { - return new SetWormholeAddressAndDataSources( + return new MigrateGovernanceAndWormhole( this.wormholeChainName, address, dataSources, + governanceDataSource, + governanceDataSourceIndex, ).encode(); }