Skip to content

contract_manager: propose + execute scripts for the SVM Wormhole guardian-set migration - #4016

Merged
guibescos merged 37 commits into
mainfrom
hydra/i-owkulcn/head
Aug 26, 2026
Merged

contract_manager: propose + execute scripts for the SVM Wormhole guardian-set migration#4016
guibescos merged 37 commits into
mainfrom
hydra/i-owkulcn/head

Conversation

@guibescos

@guibescos guibescos commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Adds the operational tooling for the SVM Wormhole guardian set migration: two
contract_manager/scripts/ entries plus the shared types they needed.

Stacked on test/wormhole-guardian-set-migration (#4013), with add-fogo's single commit
(8d4929cc8) merged in for the Fogo chain entries. Review only the last two commits.

What the migration is

Per SVM chain, three actions gated on the Squads vault:

action program
1 set_data_sources → chain 26 / PythnetPythnetPythnetPythnetPyth receiver rec5EK…
2 set_fee → 0 receiver rec5EK…
3 upgrade to the migrated build core bridge HDwcJ…

then two permissionless ones that only exist in the upgraded program: close_guardian_set
for every set that is left, and initialize, which installs the Pyth Pro multisig at
guardian set 0.

The two scripts

propose_svm_guardian_set_migration.ts builds actions 1–3 for each chain in its config
and proposes them.

It opens by dumping the whole of the on-chain state the migration touches, per chain, so an
operator can read what is actually there before approving anything: the BPF loader upgrade
authority of the remote executor (on chains the vault reaches over wormhole), the price
receiver, the push oracle and the core bridge; the receiver's config — governance authority,
pending governance authority, core bridge, data sources, fee, minimum signatures; and the
core bridge's config — guardian set index, guardian set TTL, message fee — together with
which guardian set accounts still exist and the full contents of the current one. That dump
is printed before any check runs, so a chain that fails the authority check still shows why.

It then relays a price update from the Hermes that is live today through the chain's
receiver, as a pre-flight baseline: if a price does not make it through before the
migration, the post-migration relay at the end of the execute script has nothing to be read
against, so this is a hard gate. The Hermes and the feed come as a pair from
--deployment-type, since a feed id from one instance does not exist on the other:
pro-compatible-production relays SOL/USD from https://hermes.pyth.network,
pro-compatible-staging relays it from https://hermes-beta.pyth.network.

Then it checks, per chain, that the receiver's governance_authority and the core bridge's
upgrade authority are both the key the vault governs that chain with, and that the upgrade
buffer holds byte-for-byte the locally built core bridge under an authority the loader will
accept — so a chain whose authorities have not been handed over fails here rather than as an
unexecutable proposal. --dry-run stops before the proposal is submitted, though the
pre-flight relay still sends a transaction; --chain narrows the config.

execute_svm_guardian_set_migration.ts executes the proposal, relays the governance
messages it emitted, runs the permissionless steps, and finishes by pulling a price update
from the new Hermes and relaying it through the migrated receiver — the same
relayPriceUpdate the propose script runs beforehand, pointed at the Pyth Pro emitter
rather than the one that is live today.

Every step is expressed as "is the state I am about to produce already there?", so a run
that fails part way through is simply repeated — the proposal included: --proposal is
mandatory, and one that an earlier run already executed still has its governance messages
found and relayed. That also gives the ordering constraint a
real gate rather than a comment: on a chain the vault reaches over wormhole, the
governance message is verified against the very guardian sets the migration closes
, so
closing them first makes the proposal permanently unexecutable. The script refuses to close
anything until it has read the migrated ELF out of that chain's program data account.

One proposal for every chain

The vault signs instructions directly on its own cluster but has to emit a wormhole message
for chains it does not live on. Those are different kinds of multisig instruction, but they
differ only in the authority Squads signs each one with — the vault authority for a local
instruction, the per-index message PDA for a wormhole message — and Squads stores that per
instruction, so one proposal can hold both. MultisigVault.proposeActions builds exactly
that, in config order, and the execute script takes the single --proposal it produces.

Supporting types

  • SvmPriceFeedContract / SvmWormholeContract (src/core/contracts/svm.ts) with store
    JSON for solana mainnet/devnet and fogo mainnet/testnet. They implement PriceFeedContract
    and WormholeContract, so they live in contracts / wormhole_contracts alongside every
    other chain. The receiver reads its data sources and update fee off its config, reads prices
    from the push oracle's shard-0 feed accounts and writes them through the receiver SDK; the
    core bridge reports its guardian set and advances it with the legacy GuardianSetUpdate
    instruction. What has no SVM analogue throws: the receiver has no staleness threshold and is
    governed by an authority key rather than by VAAs, and the bridge does not store its chain id
    on chain. Its governance messages are ExecutePostedVaa, so executeGovernanceInstruction
    posts the VAA to the core bridge its config names and has the remote executor replay the
    instructions it carries; the executor's claim record makes a re-run a no-op. That is what
    executeVaa now dispatches to, so the generic loop — which asks for a governance data source
    first — skips the receiver.
    Accounts and instructions go through the IDLs, using the anchor Programs
    PythSolanaReceiver already owns — the published IDLs are in the pre-0.30 format that this
    package's own anchor can no longer instantiate, and the receiver SDK's anchor matches them.
    Three things still need custom handling: guardian sets are an AccountVariant, so one
    written by the original Wormhole program carries no anchor discriminator and is given a
    stand-in for decodeUnchecked to skip over; the bridge config is a legacy account too, so
    the IDL knows it as a type rather than as an account; and close_guardian_set /
    initialize are legacy single-byte selectors, the first of which exists only in the
    migrated build and so cannot come from the published IDL.
  • The ExecutePostedVaa relay, which is what the execute script uses for step 2, goes through the remote
    executor's own IDL rather than a hand-rolled sha256("global:execute_posted_vaa") and a
    literal account list. The IDL was already checked in at governance/remote_executor/idl.json
    — byte-identical to what anchor idl fetch returns from Pythnet — but nothing could import
    it, so that directory becomes a workspace package shipping the IDL and its types, the same
    arrangement pythnet/message_buffer already uses. It predates anchor 0.30, so the client is
    built in xc_admin_common, which is on an anchor that can instantiate it and already owns the
    executor's address and seeds.
  • MultisigVault.proposeActions in xc_admin_common, and Vault.proposeActions wrapping
    it, which propose a mixed list of local instructions and wormhole payloads as one proposal.
    MultisigProposal is also split out of WormholeMultisigProposal — a proposal that runs
    local instructions emits no wormhole messages, which the old execute() treated as an
    error. Nothing called the old method.
  • MultisigProposal.fetchEmittedWormholeMessages, which reads a proposal's messages back off
    its own account history rather than off the signatures a particular run produced. execute()
    returns nothing for a proposal that has already run through, so without this a re-run after a
    partial failure has nothing to relay and strands the migration on the remote chains. It also
    means no sequence number ever has to be supplied by hand. It lists those signatures at
    confirmed — Squads' own connection defaults to finalized, at which point a proposal
    executed seconds earlier has emitted nothing yet — and parses them through the node that
    listed them, so an --rpc-url override is not half ignored.
  • SvmChain.generateExecutePostedVaaPayload, which wraps one instruction per wormhole
    message, so a remote chain's three actions go out as three messages; and getKeypair, which fixes
    getAccountAddress / getAccountBalance: PrivateKey is a 32-byte hex string, which is
    an ed25519 seed, and Keypair.fromSecretKey wants the 64-byte expanded form.
  • getUpgradeInstruction / getProgramDataAddress in xc_admin_common's
    bpf_upgradable_loader, next to the loader constants that were already there.

SvmChain.generateGovernanceUpgradePayload stays unimplemented. It is abstract on Chain
and returns a Pyth governance payload; SVM programs are upgraded through the BPF loader, so
there is nothing of that shape to return.

Fogo chain names

add-fogo set wormholeChainName: "fogo", which is in neither RECEIVER_CHAINS nor the
legacy wormhole SDK. Chain's constructor throws on an unresolvable name, so DefaultStore
failed to load at all. Corrected to fogo_mainnet / fogo_testnet; their ids (40011 /
40010) are also what the remote executor deployed on Fogo checks the governance header
against, so the payloads would have been rejected regardless.

Scope

fogo_testnet cannot be migrated by this tooling as things stand: its core bridge upgrade
authority is XS3Koc1BsR3A1hVvqNQuyKEBkUrxDYdMyAyEfvUD5vr, not the vault's executor PDA, so
action 3 cannot be proposed through the vault there. Nothing hardcodes that — the propose
script's authority check simply fails on it — but the authority has to move before it can be
included.

solana_devnet cannot be migrated through a vault either, and an earlier revision of this
description was wrong to say it could. Its receiver governance_authority and core bridge
upgrade authority are both upg8KLALUN7ByDHiBu4wEbMDTC6UnSVFSYfTyGfXuzr, which is not an
authority PDA of the devnet vault 6baWtW1zTUVMSJHJQVxDUXWzqrQeYBr6mu31j3bTKwY3 — that
vault's authority index 1, the one Vault.getEmitter derives, is
7g4Los4WMQnpxYiBJpU1HejBiM6xCk5RDFGCABhWE9M6, and no index 0–4 matches. So devnet is a
plain-keypair deployment; the authority check fails on it, and it would have to be driven by
whoever holds that key.

Running any of this on fogo_mainnet still depends on the Pyth Pro multisig attesting
chain-1 xc_admin messages. If it does not, Fogo loses admin governance permanently. That is
not something these scripts can check.

@guibescos
guibescos requested a review from a team as a code owner August 21, 2026 17:32
@dourolabs-greenlight

Copy link
Copy Markdown

Greenlight — 🚫 Blocked — see reasons below (6502635)

Blocking reasons:

  • path not auto-approvable: contract_manager/package.json (+11 more)

Open in Greenlight →

@dourolabs-greenlight

dourolabs-greenlight Bot commented Aug 21, 2026

Copy link
Copy Markdown

Greenlight — 🚫 Blocked — see reasons below (10e31d3)

Blocking reasons:

  • path not auto-approvable: contract_manager/package.json (+20 more)

Open in Greenlight →

@vercel
vercel Bot temporarily deployed to Preview – insights August 21, 2026 17:32 Inactive
@vercel

vercel Bot commented Aug 21, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
api-reference Ready Ready Preview Aug 26, 2026 3:02pm
component-library Ready Ready Preview Aug 26, 2026 3:02pm
developer-hub Ready Ready Preview Aug 26, 2026 3:02pm
proposals Ready Ready Preview Aug 26, 2026 3:02pm
3 Skipped Deployments
Project Deployment Actions Updated (UTC)
entropy-explorer Skipped Skipped Aug 26, 2026 3:02pm
insights Skipped Skipped Aug 26, 2026 3:02pm
staking Skipped Skipped Aug 26, 2026 3:02pm

Request Review

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

Open in Devin Review

Comment on lines +1776 to 1777
public isRemote: boolean,
public rpcUrl: string,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Devnet test helper passes RPC URL to the wrong parameter

Inserting isRemote ahead of rpcUrl leaves the devnetChain helper (contract_manager/scripts/test_solana_lazer_contract.ts:55) passing the URL as isRemote, so rpcUrl becomes undefined and every connection that chain opens throws.

Prompt for agents
The SvmChain constructor gained a new positional parameter isRemote inserted before rpcUrl. The call site devnetChain() in contract_manager/scripts/test_solana_lazer_contract.ts still uses the old 5-argument positional form new SvmChain("solana_devnet", false, "solana", "SOL", rpcUrl), which now binds the URL string to isRemote and leaves rpcUrl undefined. Update that call to pass isRemote (false for a devnet solana chain) before rpcUrl so the constructed chain has a valid rpcUrl.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@guibescos
guibescos force-pushed the hydra/i-owkulcn/head branch from 6502635 to adde387 Compare August 21, 2026 18:25
@vercel
vercel Bot temporarily deployed to Preview – insights August 21, 2026 18:25 Inactive
@vercel
vercel Bot temporarily deployed to Preview – entropy-explorer August 21, 2026 18:25 Inactive
@vercel
vercel Bot temporarily deployed to Preview – staking August 21, 2026 18:25 Inactive
@vercel
vercel Bot temporarily deployed to Preview – insights August 21, 2026 18:32 Inactive
@vercel
vercel Bot temporarily deployed to Preview – staking August 21, 2026 18:32 Inactive
@vercel
vercel Bot temporarily deployed to Preview – entropy-explorer August 21, 2026 18:32 Inactive
@vercel
vercel Bot temporarily deployed to Preview – proposals August 21, 2026 18:32 Inactive
@vercel
vercel Bot temporarily deployed to Preview – insights August 21, 2026 18:54 Inactive
@vercel
vercel Bot temporarily deployed to Preview – staking August 21, 2026 18:54 Inactive
guibescos and others added 28 commits August 26, 2026 13:34
… executed

--proposal is now mandatory: without it the execute script silently relayed
nothing and went straight on to closing guardian sets.

Its governance messages are now read back off the proposal account instead of
off the signatures the current run produced. execute() returns nothing for a
proposal an earlier run already took through, so a re-run after a partial
failure had no messages to relay and stranded the migration on remote chains.
…ecute script

Sequence numbers no longer have to be supplied by hand: they come off the
proposal's own transaction history, which covers the case --sequence existed
for — relaying the messages of a proposal an earlier run already executed.
…e IDLs

Replaces the hand-written borsh reader and anchor discriminators in
src/core/contracts/svm.ts with the receiver SDK's anchor clients, which already
carry the receiver and core bridge IDLs.

Three things still need custom handling and say why in place: guardian sets are
an AccountVariant, so a legacy account without an anchor discriminator gets a
stand-in to skip over; the bridge config is a legacy account too, so the IDL
knows it as a type rather than an account; and close_guardian_set / initialize
are legacy selectors, the first of which only exists in the migrated build and
so cannot come from the published IDL.
The relay path hand-rolled the remote executor's wire format: a sha256 of
`global:execute_posted_vaa` for the instruction discriminator, a literal account
list, and a `readBigUInt64LE(8)` past the anchor discriminator to read
`ClaimRecord.sequence`.

The IDL the deployed program publishes is already checked in at
`governance/remote_executor/idl.json` — byte-identical to what `anchor idl fetch`
returns from Pythnet — but nothing could import it. Make that directory a
workspace package shipping the IDL and its types, the same arrangement
`pythnet/message_buffer` already uses, and build an anchor client from it.

The IDL predates anchor 0.30, so only an anchor of that era can instantiate a
`Program` from it. `xc_admin_common` is on one and already owns the executor's
address and seeds, so the client lives there next to them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every chain in the config now goes into a single multisig proposal, whether the
vault signs for it directly or reaches it over wormhole. The two kinds differ
only in the authority Squads signs each instruction with — a local instruction
uses the vault authority, a wormhole message the per-index message PDA — so they
can share a proposal, which `MultisigVault.proposeActions` now does.

The execute script correspondingly takes one `--proposal`: it executes what has
not run yet, then relays whatever wormhole messages the proposal emitted, which
is empty when no remote chain was involved.

Two fixes fell out of testing that path against a real Squads deployment:
`fetchEmittedWormholeMessages` listed signatures at the squads connection's
default `finalized` commitment, so messages from a proposal executed moments
earlier were silently missed; and it parsed those transactions through the
default public RPC rather than the node that listed them, ignoring `--rpc-url`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Keep only the comments whose reasoning is not in the code: the account
layouts read by offset, the anchor and legacy-account workarounds, and
the ordering constraints between the migration's steps.
…nterfaces

SvmPriceFeedContract now extends PriceFeedContract and SvmWormholeContract
extends WormholeContract, so both live in DefaultStore.contracts and
DefaultStore.wormhole_contracts instead of registries of their own.

The receiver reads its data sources and update fee off its config, reads
prices from the push oracle's shard-0 feed accounts and writes them through
the receiver SDK. The core bridge reports its current guardian set and
advances it with the legacy GuardianSetUpdate instruction.

The methods with no SVM analogue throw: the receiver has no staleness
threshold and is governed by an authority key rather than by VAAs, and the
bridge does not store its chain id on chain.
The build tool rewrites each package's package.json / tsconfig.build.json
with biome, which picked up six files this change does not touch.
…e SVM receiver

The receiver's governance messages are ExecutePostedVaa: post the VAA to
the core bridge its config names, then have the remote executor replay the
instructions it carries, signed by the PDA the config names as governance
authority. The claim record makes a re-run a no-op.

This moves executeThroughRemoteExecutor out of executor.ts and onto the
contract, which drops the core bridge lookup with it: the bridge to post to
is the one the receiver itself verifies against, which is also the owner the
remote executor requires of the posted VAA account.
Squads builds its connection with web3.js' finalized default, which is far
behind what the proposals it reads were just written at. Setting it once at
construction covers every read the client makes, rather than the one call
site that had noticed.
… at a time

Read them off the cluster's own RPC rather than threading the squads
endpoint through, and let the helper take a single signature and report
"no message here" by returning undefined.
…he proposal

Pass the squads endpoint to fromTransactionSignature so the transactions are
parsed on the node that listed them, and drop the one-line helper that stood
between the two.
…ting

Mirrors the propose script's dump, so the two runs produce comparable
before-and-after pictures of what the migration changed.
This reverts commit 5cc9760.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants