From a412d1cb154eb8b73b2adba0e90788422d7de04a Mon Sep 17 00:00:00 2001 From: csongor Date: Sun, 26 Apr 2026 14:58:46 +0200 Subject: [PATCH 1/2] cli: add command to cancel in-flight SVM ownership transfer --- cli/src/__tests__/cli-help.test.ts | 1 + cli/src/commands/solana.ts | 150 +++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+) diff --git a/cli/src/__tests__/cli-help.test.ts b/cli/src/__tests__/cli-help.test.ts index 495c40cdf..87332ba77 100644 --- a/cli/src/__tests__/cli-help.test.ts +++ b/cli/src/__tests__/cli-help.test.ts @@ -52,6 +52,7 @@ const SOLANA_SUBCOMMANDS = [ "ata", "create-spl-multisig", "build", + "cancel-ownership-transfer", ]; const CONFIG_SUBCOMMANDS = ["set-chain", "unset-chain", "get-chain"]; diff --git a/cli/src/commands/solana.ts b/cli/src/commands/solana.ts index dcb8299b9..e842febc9 100644 --- a/cli/src/commands/solana.ts +++ b/cli/src/commands/solana.ts @@ -6,6 +6,7 @@ import { encoding } from "@wormhole-foundation/sdk-connect"; import { Wormhole, chainToPlatform, + chains, toUniversal, type Chain, } from "@wormhole-foundation/sdk"; @@ -18,6 +19,7 @@ import { Keypair, PublicKey, SendTransactionError, + Transaction, } from "@solana/web3.js"; import * as spl from "@solana/spl-token"; @@ -384,6 +386,154 @@ export function createSolanaCommand( console.log(`Keypair: ${buildResult.programKeypairPath}`); } ) + .command( + "cancel-ownership-transfer ", + "cancel an in-progress NTT manager ownership transfer", + (yargs: any) => + yargs + .positional("chain", { + ...options.chain, + choices: chains.filter((c) => chainToPlatform(c) === "Solana"), + }) + .option("path", options.deploymentPath) + .option("yes", options.yes) + .option("payer", { ...options.payer, demandOption: true }) + .example( + "$0 svm cancel-ownership-transfer Solana --payer ", + "Cancel a pending ownership transfer on Solana, restoring upgrade authority to the current owner" + ), + async (argv: any) => { + const path = argv["path"]; + const deployments: Config = loadConfig(path); + const chain: Chain = argv["chain"]; + const network = deployments.network as Network; + + const platform = chainToPlatform(chain); + if (platform !== "Solana") { + console.error( + `cancel-ownership-transfer is only supported for SVM chains. Got platform: ${platform}` + ); + process.exit(1); + } + + const payerPath = validatePayerOption( + argv["payer"], + chain, + (message) => new Error(message), + (message) => console.warn(colors.yellow(message)) + ); + if (!payerPath) { + console.error("Payer not found. Specify with --payer"); + process.exit(1); + } + const payerKeypair = Keypair.fromSecretKey( + new Uint8Array(JSON.parse(fs.readFileSync(payerPath).toString())) + ); + + if (!(chain in deployments.chains)) { + console.error( + `Chain ${chain} not found in deployment configuration` + ); + process.exit(1); + } + + const chainConfig = deployments.chains[chain]!; + console.log( + `Cancelling ownership transfer on ${chain} (${network})` + ); + console.log(`Manager address: ${chainConfig.manager}`); + + const [, , ntt] = await pullChainConfig( + network, + { chain, address: toUniversal(chain, chainConfig.manager) }, + overrides + ); + const solanaNtt = ntt as SolanaNtt; + + const config = await solanaNtt.getConfig(); + const currentOwner = config.owner; + const pendingOwner = config.pendingOwner; + + console.log(`Current owner: ${currentOwner.toBase58()}`); + + if (!pendingOwner) { + console.error( + "No ownership transfer in progress (pending_owner is null). Nothing to cancel." + ); + process.exit(1); + } + + console.log(`Pending owner: ${pendingOwner.toBase58()}`); + + if (!currentOwner.equals(payerKeypair.publicKey)) { + console.error( + `Payer ${payerKeypair.publicKey.toBase58()} is not the current owner. Only the current owner can cancel a pending ownership transfer.` + ); + process.exit(1); + } + + if (!argv["yes"]) { + await askForConfirmation( + `Cancel pending ownership transfer to ${pendingOwner.toBase58()}?` + ); + } + + const wh = new Wormhole(network, [solana.Platform], overrides); + const ch = wh.getChain(chain); + const connection: Connection = await ch.getRpc(); + + try { + // Cancellation is performed by the current owner re-invoking + // claim_ownership. This restores the upgrade authority from the + // upgrade_lock PDA back to the owner and clears pending_owner. + const ix = await NTT.createClaimOwnershipInstruction( + solanaNtt.program, + { newOwner: currentOwner } + ); + + const tx = new Transaction().add(ix); + tx.feePayer = payerKeypair.publicKey; + const { blockhash, lastValidBlockHeight } = + await connection.getLatestBlockhash(); + tx.recentBlockhash = blockhash; + tx.sign(payerKeypair); + + const signature = await connection.sendRawTransaction( + tx.serialize() + ); + console.log(`Transaction signature: ${signature}`); + console.log(`Waiting for finalization...`); + await connection.confirmTransaction( + { signature, blockhash, lastValidBlockHeight }, + "finalized" + ); + + const refreshed = await NTT.getConfig( + solanaNtt.program, + solanaNtt.pdas + ); + if (refreshed.pendingOwner === null) { + console.log("Pending ownership transfer cancelled"); + } else { + console.error( + `Cancellation verification failed; pending_owner is still ${refreshed.pendingOwner.toBase58()}` + ); + process.exit(1); + } + } catch (error: any) { + if (error instanceof SendTransactionError) { + console.error("Failed to cancel ownership transfer:"); + console.error(error.logs); + } else { + console.error( + "Failed to cancel ownership transfer:", + error.message ?? error + ); + } + process.exit(1); + } + } + ) .demandCommand(); }, handler: (_argv: any) => {}, From 1e65e369d40880ed24637a2f7e850f6a1df7be7d Mon Sep 17 00:00:00 2001 From: csongor Date: Sun, 26 Apr 2026 15:02:06 +0200 Subject: [PATCH 2/2] cli: don't require us to be a signer for un-modified chains --- cli/src/commands/push.ts | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/cli/src/commands/push.ts b/cli/src/commands/push.ts index 212544531..06b0eee17 100644 --- a/cli/src/commands/push.ts +++ b/cli/src/commands/push.ts @@ -20,10 +20,35 @@ import { getSigner, type SignerType } from "../signers/getSigner"; import { newSignSendWaiter } from "../signers/signSendWait.js"; import { registerSolanaTransceiver } from "../solana/transceiver"; import { collectMissingConfigs, validatePayerOption } from "../validation"; -import type { Deployment } from "../validation"; -import { options } from "./shared"; +import type { Deployment, MissingImplicitConfig } from "../validation"; +import { diffObjects } from "../diff"; +import { options, EXCLUDED_DIFF_PATHS } from "./shared"; import { pullDeployments, checkConfigErrors, pushDeployment } from "../index"; +function hasPendingChanges( + deployment: Deployment, + missing: MissingImplicitConfig | undefined +): boolean { + if ( + missing && + (missing.managerPeers.length > 0 || + missing.transceiverPeers.length > 0 || + missing.solanaWormholeTransceiver || + missing.solanaUpdateLUT) + ) { + return true; + } + const local = deployment.config.local; + const remote = deployment.config.remote; + if (!local || !remote) { + // If either side is missing, defer to existing handling — treat as + // "needs work" so the original code paths run. + return true; + } + const diff = diffObjects(local, remote, EXCLUDED_DIFF_PATHS); + return Object.keys(diff).length > 0; +} + export function createPushCommand(overrides: WormholeConfigOverrides) { return { command: "push", @@ -132,6 +157,11 @@ export function createPushCommand(overrides: WormholeConfigOverrides) { } assertChain(chain); if (chainToPlatform(chain) === "Evm") { + if (!hasPendingChanges(deps[chain]!, missing[chain])) { + // No-op for this chain — don't require ownership of contracts the + // user isn't trying to modify. + continue; + } const ntt = deps[chain]!.ntt; const ctx = deps[chain]!.ctx; const signer = await getSigner(ctx, signerType, undefined, payerPath);