Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cli/src/__tests__/cli-help.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const SOLANA_SUBCOMMANDS = [
"ata",
"create-spl-multisig",
"build",
"cancel-ownership-transfer",
];

const CONFIG_SUBCOMMANDS = ["set-chain", "unset-chain", "get-chain"];
Expand Down
34 changes: 32 additions & 2 deletions cli/src/commands/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(

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.

this seems like it'd be worth some unit tests?

deployment: Deployment<Chain>,
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<Network>) {
return {
command: "push",
Expand Down Expand Up @@ -132,6 +157,11 @@ export function createPushCommand(overrides: WormholeConfigOverrides<Network>) {
}
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);
Expand Down
150 changes: 150 additions & 0 deletions cli/src/commands/solana.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { encoding } from "@wormhole-foundation/sdk-connect";
import {
Wormhole,
chainToPlatform,
chains,
toUniversal,
type Chain,
} from "@wormhole-foundation/sdk";
Expand All @@ -18,6 +19,7 @@ import {
Keypair,
PublicKey,
SendTransactionError,
Transaction,
} from "@solana/web3.js";
import * as spl from "@solana/spl-token";

Expand Down Expand Up @@ -384,6 +386,154 @@ export function createSolanaCommand(
console.log(`Keypair: ${buildResult.programKeypairPath}`);
}
)
.command(
"cancel-ownership-transfer <chain>",
"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 <SOLANA_KEYPAIR_PATH>",
"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<typeof network, SolanaChains>;

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"
);
Comment on lines +494 to +509

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.

consider using signSendWait like the rest of the CLI


const refreshed = await NTT.getConfig(
solanaNtt.program,
solanaNtt.pdas
);
if (refreshed.pendingOwner === null) {

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.

I wonder how useful this sanity check is afterwards - it could possibly read stale state from a load balanced endpoint but maybe unlikely and worth keeping.

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) => {},
Expand Down
Loading