contract_manager: propose + execute scripts for the SVM Wormhole guardian-set migration - #4016
Merged
Conversation
|
Greenlight — 🚫 Blocked — see reasons below ( Blocking reasons:
|
|
Greenlight — 🚫 Blocked — see reasons below ( Blocking reasons:
|
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Comment on lines
+1776
to
1777
| public isRemote: boolean, | ||
| public rpcUrl: string, |
Contributor
There was a problem hiding this comment.
🟡 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
guibescos
force-pushed
the
hydra/i-owkulcn/head
branch
from
August 21, 2026 18:25
6502635 to
adde387
Compare
… 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.
keyvankhademi
approved these changes
Aug 26, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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), withadd-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:
set_data_sources→ chain 26 /PythnetPythnetPythnetPythnetPythrec5EK…set_fee→ 0rec5EK…HDwcJ…then two permissionless ones that only exist in the upgraded program:
close_guardian_setfor every set that is left, and
initialize, which installs the Pyth Pro multisig atguardian set 0.
The two scripts
propose_svm_guardian_set_migration.tsbuilds actions 1–3 for each chain in its configand 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-productionrelays SOL/USD fromhttps://hermes.pyth.network,pro-compatible-stagingrelays it fromhttps://hermes-beta.pyth.network.Then it checks, per chain, that the receiver's
governance_authorityand the core bridge'supgrade 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-runstops before the proposal is submitted, though thepre-flight relay still sends a transaction;
--chainnarrows the config.execute_svm_guardian_set_migration.tsexecutes the proposal, relays the governancemessages 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
relayPriceUpdatethe propose script runs beforehand, pointed at the Pyth Pro emitterrather 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:
--proposalismandatory, 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.proposeActionsbuilds exactlythat, in config order, and the execute script takes the single
--proposalit produces.Supporting types
SvmPriceFeedContract/SvmWormholeContract(src/core/contracts/svm.ts) with storeJSON for solana mainnet/devnet and fogo mainnet/testnet. They implement
PriceFeedContractand
WormholeContract, so they live incontracts/wormhole_contractsalongside everyother 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
GuardianSetUpdateinstruction. 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, soexecuteGovernanceInstructionposts 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
executeVaanow dispatches to, so the generic loop — which asks for a governance data sourcefirst — skips the receiver.
Accounts and instructions go through the IDLs, using the anchor
ProgramsPythSolanaReceiveralready owns — the published IDLs are in the pre-0.30 format that thispackage'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 onewritten by the original Wormhole program carries no anchor discriminator and is given a
stand-in for
decodeUncheckedto skip over; the bridge config is a legacy account too, sothe IDL knows it as a type rather than as an account; and
close_guardian_set/initializeare legacy single-byte selectors, the first of which exists only in themigrated build and so cannot come from the published IDL.
ExecutePostedVaarelay, which is what the execute script uses for step 2, goes through the remoteexecutor's own IDL rather than a hand-rolled
sha256("global:execute_posted_vaa")and aliteral account list. The IDL was already checked in at
governance/remote_executor/idl.json— byte-identical to what
anchor idl fetchreturns from Pythnet — but nothing could importit, so that directory becomes a workspace package shipping the IDL and its types, the same
arrangement
pythnet/message_bufferalready uses. It predates anchor 0.30, so the client isbuilt in
xc_admin_common, which is on an anchor that can instantiate it and already owns theexecutor's address and seeds.
MultisigVault.proposeActionsinxc_admin_common, andVault.proposeActionswrappingit, which propose a mixed list of local instructions and wormhole payloads as one proposal.
MultisigProposalis also split out ofWormholeMultisigProposal— a proposal that runslocal instructions emits no wormhole messages, which the old
execute()treated as anerror. Nothing called the old method.
MultisigProposal.fetchEmittedWormholeMessages, which reads a proposal's messages back offits 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 tofinalized, at which point a proposalexecuted seconds earlier has emitted nothing yet — and parses them through the node that
listed them, so an
--rpc-urloverride is not half ignored.SvmChain.generateExecutePostedVaaPayload, which wraps one instruction per wormholemessage, so a remote chain's three actions go out as three messages; and
getKeypair, which fixesgetAccountAddress/getAccountBalance:PrivateKeyis a 32-byte hex string, which isan ed25519 seed, and
Keypair.fromSecretKeywants the 64-byte expanded form.getUpgradeInstruction/getProgramDataAddressinxc_admin_common'sbpf_upgradable_loader, next to the loader constants that were already there.SvmChain.generateGovernanceUpgradePayloadstays unimplemented. It is abstract onChainand 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-fogosetwormholeChainName: "fogo", which is in neitherRECEIVER_CHAINSnor thelegacy wormhole SDK.
Chain's constructor throws on an unresolvable name, soDefaultStorefailed 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_testnetcannot be migrated by this tooling as things stand: its core bridge upgradeauthority is
XS3Koc1BsR3A1hVvqNQuyKEBkUrxDYdMyAyEfvUD5vr, not the vault's executor PDA, soaction 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_devnetcannot be migrated through a vault either, and an earlier revision of thisdescription was wrong to say it could. Its receiver
governance_authorityand core bridgeupgrade authority are both
upg8KLALUN7ByDHiBu4wEbMDTC6UnSVFSYfTyGfXuzr, which is not anauthority PDA of the devnet vault
6baWtW1zTUVMSJHJQVxDUXWzqrQeYBr6mu31j3bTKwY3— thatvault's authority index 1, the one
Vault.getEmitterderives, is7g4Los4WMQnpxYiBJpU1HejBiM6xCk5RDFGCABhWE9M6, and no index 0–4 matches. So devnet is aplain-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_mainnetstill depends on the Pyth Pro multisig attestingchain-1 xc_admin messages. If it does not, Fogo loses admin governance permanently. That is
not something these scripts can check.