Skip to content

Commit 0ad1b81

Browse files
committed
cli: add svm deploy-program command
1 parent 73f9855 commit 0ad1b81

5 files changed

Lines changed: 222 additions & 51 deletions

File tree

cli/src/__tests__/cli-help.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ const SOLANA_SUBCOMMANDS = [
5252
"ata",
5353
"create-spl-multisig",
5454
"build",
55+
"deploy-program",
5556
];
5657

5758
const CONFIG_SUBCOMMANDS = ["set-chain", "unset-chain", "get-chain"];
@@ -150,6 +151,8 @@ describe("Command-specific options", () => {
150151
expect(stdout).toContain("--token");
151152
expect(stdout).toContain("--latest");
152153
expect(stdout).toContain("--skip-verify");
154+
expect(stdout).toContain("--deploy-program");
155+
expect(stdout).toContain("--instance-of");
153156
});
154157

155158
it("push shows --yes option", async () => {

cli/src/commands/add-chain.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,15 @@ export function createAddChainCommand(
7474
})
7575
.option("instance-of", {
7676
describe:
77-
"(SVM v4 only) Skip program deploy and create a new instance under the existing program at this address. Mutually exclusive with --binary/--program-key.",
77+
"(SVM v4 only) Skip program deploy and create a new instance under the existing program at this address. Mutually exclusive with --deploy-program/--binary/--program-key.",
7878
type: "string" as const,
7979
})
80+
.option("deploy-program", {
81+
describe:
82+
"(SVM) Deploy a fresh program AND create the first instance under it. One of --deploy-program or --instance-of is required for Solana. For v4+ the recommended flow is to first run `ntt solana deploy-program` and then `add-chain --instance-of <programId>`.",
83+
type: "boolean" as const,
84+
default: false,
85+
})
8086
.option("instance-key", {
8187
describe:
8288
"(SVM v4 only) Path to the keypair file to use as the Instance account. Defaults to a freshly-generated keypair (written to `<chain>-instance.json` in the deployment dir).",
@@ -303,6 +309,38 @@ export function createAddChainCommand(
303309
// v4 multi-host path: --instance-of <programId> creates a fresh Instance
304310
// under the existing program rather than deploying a new one.
305311
const instanceOf = argv["instance-of"] as string | undefined;
312+
const deployProgramFlag = Boolean(argv["deploy-program"]);
313+
314+
if (platform === "Solana") {
315+
if (!instanceOf && !deployProgramFlag) {
316+
console.error(
317+
colors.red(
318+
"Solana add-chain now requires one of:\n" +
319+
" --deploy-program deploy a fresh program AND create the first instance under it.\n" +
320+
" --instance-of <programId> create an instance under an existing program (v4+).\n" +
321+
"\n" +
322+
"For v4+ the recommended flow is two steps:\n" +
323+
" 1. ntt solana deploy-program <chain> --payer <payer.json> [--ver <version> | --latest]\n" +
324+
" 2. ntt add-chain <chain> --instance-of <programId> --token <mint> --mode <locking|burning> --payer <payer.json>"
325+
)
326+
);
327+
process.exit(1);
328+
}
329+
if (instanceOf && deployProgramFlag) {
330+
console.error(
331+
colors.red(
332+
"--instance-of and --deploy-program are mutually exclusive"
333+
)
334+
);
335+
process.exit(1);
336+
}
337+
} else if (deployProgramFlag) {
338+
console.error(
339+
colors.red("--deploy-program is only supported on Solana")
340+
);
341+
process.exit(1);
342+
}
343+
306344
if (instanceOf !== undefined) {
307345
if (platform !== "Solana") {
308346
console.error(

cli/src/commands/solana.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
askForConfirmation,
3838
buildSvm,
3939
createWorkTree,
40+
uploadSolanaProgram,
4041
} from "../index";
4142

4243
export function createSolanaCommand(
@@ -400,6 +401,114 @@ export function createSolanaCommand(
400401
console.log(`Keypair: ${buildResult.programKeypairPath}`);
401402
}
402403
)
404+
.command(
405+
"deploy-program <chain>",
406+
"build (or reuse) and upload the SVM program binary, without initializing an instance",
407+
(yargs: any) =>
408+
yargs
409+
.positional("chain", options.chain)
410+
.option("network", options.network)
411+
.option("payer", { ...options.payer, demandOption: true })
412+
.option("program-key", {
413+
describe: "Path to program key json",
414+
type: "string",
415+
})
416+
.option("binary", {
417+
describe:
418+
"Path to pre-built program binary (.so file); skips build if provided",
419+
type: "string",
420+
})
421+
.option("solana-priority-fee", {
422+
describe: "Priority fee for SVM deployment (in microlamports)",
423+
type: "number",
424+
default: 50000,
425+
})
426+
.option("ver", options.version)
427+
.option("latest", options.latest)
428+
.option("local", options.local)
429+
.example(
430+
"$0 svm deploy-program Solana --network Mainnet --latest --payer ./payer.json",
431+
"Build and deploy the latest program; no instance is created"
432+
)
433+
.example(
434+
"$0 svm deploy-program Solana --network Mainnet --ver 4.0.0 --payer ./payer.json --program-key ./prog-keypair.json",
435+
"Deploy a specific version with a chosen program keypair (run `ntt add-chain Solana --instance-of <programId>` next)"
436+
),
437+
async (argv: any) => {
438+
const chain: Chain = argv["chain"];
439+
const network = argv["network"] as Network;
440+
441+
const platform = chainToPlatform(chain);
442+
if (platform !== "Solana") {
443+
console.error(
444+
`deploy-program is only supported for Solana chains. Got platform: ${platform}`
445+
);
446+
process.exit(1);
447+
}
448+
449+
validateChain(network, chain);
450+
451+
const payerPath = validatePayerOption(
452+
argv["payer"],
453+
chain,
454+
(m) => new Error(m),
455+
(m) => console.warn(colors.yellow(m))
456+
);
457+
if (!payerPath) {
458+
console.error("Payer not found. Specify with --payer");
459+
process.exit(1);
460+
}
461+
462+
const version = resolveVersion(
463+
argv["latest"],
464+
argv["ver"],
465+
argv["local"],
466+
platform
467+
);
468+
469+
const worktree = version ? createWorkTree(platform, version) : ".";
470+
471+
const wh = new Wormhole(
472+
network,
473+
[solana.Platform, evm.Platform, sui.Platform],
474+
overrides
475+
);
476+
const ch = wh.getChain(chain);
477+
const wormhole = ch.config.contracts.coreBridge;
478+
if (!wormhole) {
479+
console.error("Core bridge not found");
480+
process.exit(1);
481+
}
482+
483+
console.log(`Building SVM program for ${chain} on ${network}...`);
484+
const { binary, programId, programKeypairPath } = await buildSvm(
485+
worktree,
486+
network,
487+
chain,
488+
wormhole,
489+
version,
490+
argv["program-key"],
491+
argv["binary"],
492+
overrides
493+
);
494+
495+
console.log(
496+
`Deploying program ${programId} to ${chain} ${network}...`
497+
);
498+
await uploadSolanaProgram({
499+
binary,
500+
programKeypairPath,
501+
payerPath,
502+
rpc: ch.config.rpc,
503+
priorityFee: argv["solana-priority-fee"],
504+
});
505+
506+
console.log(`Deployed program: ${colors.green(programId)}`);
507+
console.log(
508+
`Next: \`ntt add-chain ${chain} --instance-of ${programId} --token <mint> --mode <locking|burning> --payer ${payerPath}\``
509+
);
510+
}
511+
)
403512
.demandCommand();
404513
},
405514
handler: (_argv: any) => {},

cli/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ export { parseCclFlag, confirmCustomFinality } from "./commands/shared";
116116
export { deploy, upgrade } from "./deploy";
117117

118118
// Re-exports from solana/deploy.ts
119-
export { buildSvm } from "./solana/deploy";
119+
export { buildSvm, uploadSolanaProgram } from "./solana/deploy";
120120

121121
// Re-exports from config-mgmt.ts
122122
export {

cli/src/solana/deploy.ts

Lines changed: 70 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,69 @@ export async function buildSvm(
270270
};
271271
}
272272

273+
/**
274+
* Upload a built program binary via `solana program deploy`.
275+
*
276+
* Manages a `buffer.json` keypair in the current directory so an interrupted
277+
* deploy can be resumed: the helper creates `buffer.json` if it's missing,
278+
* prompts the user when an existing one is found (so they can decide whether
279+
* to resume or delete it), and removes it only after a successful upload.
280+
*
281+
* Calls `process.exit` on non-zero exit from `solana program deploy` so
282+
* callers don't have to thread the failure back themselves.
283+
*/
284+
export async function uploadSolanaProgram(args: {
285+
binary: string;
286+
programKeypairPath: string;
287+
payerPath: string;
288+
rpc: string;
289+
priorityFee?: number;
290+
}): Promise<void> {
291+
if (!fs.existsSync(`buffer.json`)) {
292+
execSync(`solana-keygen new -o buffer.json --no-bip39-passphrase`);
293+
} else {
294+
console.info("buffer.json already exists.");
295+
await askForConfirmation(
296+
"Do you want continue an exiting deployment? If not, delete the buffer.json file and run the command again."
297+
);
298+
}
299+
300+
const deployCommand = [
301+
"solana",
302+
"program",
303+
"deploy",
304+
"--program-id",
305+
args.programKeypairPath,
306+
"--buffer",
307+
`buffer.json`,
308+
args.binary,
309+
"--keypair",
310+
args.payerPath,
311+
"-u",
312+
args.rpc,
313+
"--commitment",
314+
"finalized",
315+
];
316+
317+
if (args.priorityFee !== undefined) {
318+
deployCommand.push(
319+
"--with-compute-unit-price",
320+
args.priorityFee.toString()
321+
);
322+
}
323+
324+
const deployProc = Bun.spawn(deployCommand);
325+
const out = await new Response(deployProc.stdout).text();
326+
await deployProc.exited;
327+
328+
if (deployProc.exitCode !== 0) {
329+
process.exit(deployProc.exitCode ?? 1);
330+
}
331+
332+
fs.unlinkSync("buffer.json");
333+
console.log(out);
334+
}
335+
273336
export async function deploySvm<N extends Network, C extends SolanaChains>(
274337
pwd: string,
275338
version: string | null,
@@ -437,55 +500,13 @@ export async function deploySvm<N extends Network, C extends SolanaChains>(
437500
}
438501

439502
// Deploy the binary (patching was already done during build for legacy builds on non-Solana chains)
440-
const skipDeploy = false;
441-
442-
if (!skipDeploy) {
443-
// if buffer.json doesn't exist, create it
444-
if (!fs.existsSync(`buffer.json`)) {
445-
execSync(`solana-keygen new -o buffer.json --no-bip39-passphrase`);
446-
} else {
447-
console.info("buffer.json already exists.");
448-
await askForConfirmation(
449-
"Do you want continue an exiting deployment? If not, delete the buffer.json file and run the command again."
450-
);
451-
}
452-
453-
const deployCommand = [
454-
"solana",
455-
"program",
456-
"deploy",
457-
"--program-id",
458-
programKeypairPath,
459-
"--buffer",
460-
`buffer.json`,
461-
binary,
462-
"--keypair",
463-
payer,
464-
"-u",
465-
ch.config.rpc,
466-
"--commitment",
467-
"finalized",
468-
];
469-
470-
if (priorityFee !== undefined) {
471-
deployCommand.push("--with-compute-unit-price", priorityFee.toString());
472-
}
473-
474-
const deployProc = Bun.spawn(deployCommand);
475-
476-
const out = await new Response(deployProc.stdout).text();
477-
478-
await deployProc.exited;
479-
480-
if (deployProc.exitCode !== 0) {
481-
process.exit(deployProc.exitCode ?? 1);
482-
}
483-
484-
// success. remove buffer.json
485-
fs.unlinkSync("buffer.json");
486-
487-
console.log(out);
488-
}
503+
await uploadSolanaProgram({
504+
binary,
505+
programKeypairPath,
506+
payerPath: payer,
507+
rpc: ch.config.rpc,
508+
priorityFee,
509+
});
489510

490511
if (initialize) {
491512
// wait 3 seconds

0 commit comments

Comments
 (0)