diff --git a/cli/package.json b/cli/package.json
index 1eae657817..353370cc48 100644
--- a/cli/package.json
+++ b/cli/package.json
@@ -121,9 +121,10 @@
"test-decompress-sol": "mocha ./test/commands/decompress-sol/index.test.ts -t 10000000 --exit",
"test-compress-spl": "mocha ./test/commands/compress-spl/index.test.ts -t 10000000 --exit",
"test-decompress-spl": "mocha ./test/commands/decompress-spl/index.test.ts -t 10000000 --exit",
+ "test-test-validator": "mocha ./test/commands/test-validator/index.test.ts -t 10000000 --exit",
"kill": "killall solana-test-validator || true && killall solana-test-val || true && sleep 1",
"test-cli": "pnpm test-config && pnpm kill",
- "test": "pnpm kill && pnpm test-cli && pnpm test-utils && pnpm test-create-mint && pnpm test-mint-to && pnpm test-transfer && pnpm test-merge-token-accounts && pnpm test-create-token-pool && pnpm test-compress-spl && pnpm test-decompress-spl && pnpm test-balance && pnpm test-compress-sol && pnpm test-decompress-sol && pnpm test-approve-and-mint-to",
+ "test": "pnpm kill && pnpm test-cli && pnpm test-utils && pnpm test-create-mint && pnpm test-mint-to && pnpm test-transfer && pnpm test-merge-token-accounts && pnpm test-create-token-pool && pnpm test-compress-spl && pnpm test-decompress-spl && pnpm test-balance && pnpm test-compress-sol && pnpm test-decompress-sol && pnpm test-approve-and-mint-to && pnpm test-test-validator",
"install-local": "pnpm build && pnpm global remove @lightprotocol/zk-compression-cli || true && pnpm global add $PWD",
"version": "oclif readme && git add README.md"
},
diff --git a/cli/src/commands/test-validator/index.ts b/cli/src/commands/test-validator/index.ts
index 554e2ba6cd..570dad4be6 100644
--- a/cli/src/commands/test-validator/index.ts
+++ b/cli/src/commands/test-validator/index.ts
@@ -1,5 +1,9 @@
import { Command, Flags } from "@oclif/core";
-import { initTestEnv, stopTestEnv } from "../../utils/initTestEnv";
+import {
+ initTestEnv,
+ stopTestEnv,
+ SYSTEM_PROGRAMS,
+} from "../../utils/initTestEnv";
import { CustomLoader } from "../../utils/index";
import path from "path";
import fs from "fs";
@@ -13,9 +17,13 @@ class SetupCommand extends Command {
"$ light test-validator --skip-indexer",
"$ light test-validator --geyser-config ./config.json",
'$ light test-validator --validator-args "--limit-ledger-size 50000000"',
+ "$ light test-validator --sbf-program
",
];
- protected finally(_: Error | undefined): Promise {
+ protected finally(err: Error | undefined): Promise {
+ if (err) {
+ console.error(err);
+ }
process.exit();
}
@@ -119,8 +127,55 @@ class SetupCommand extends Command {
required: false,
exclusive: ["geyser-config"],
}),
+ "sbf-program": Flags.string({
+ description:
+ "Add a SBF program to the genesis configuration with upgrades disabled. If the ledger already exists then this parameter is silently ignored. First argument can be a pubkey string or path to a keypair",
+ required: false,
+ multiple: true,
+ summary: "Usage: --sbf-program ",
+ }),
};
+ validatePrograms(programs: { address: string; path: string }[]): void {
+ // Check for duplicate addresses among provided programs
+ const addresses = new Set();
+ for (const program of programs) {
+ if (addresses.has(program.address)) {
+ this.error(`Duplicate program address detected: ${program.address}`);
+ }
+ addresses.add(program.address);
+
+ // Get the program filename from the path
+ const programFileName = path.basename(program.path);
+
+ // Check for collisions with system programs (both address and filename)
+ const systemProgramCollision = SYSTEM_PROGRAMS.find(
+ (sysProg) =>
+ sysProg.id === program.address ||
+ (sysProg.name && programFileName === sysProg.name),
+ );
+
+ if (systemProgramCollision) {
+ const collisionType =
+ systemProgramCollision.id === program.address
+ ? `address (${program.address})`
+ : `filename (${programFileName})`;
+
+ this.error(
+ `Program ${collisionType} collides with system program ` +
+ `"${systemProgramCollision.name || systemProgramCollision.id}". ` +
+ `System programs cannot be overwritten.`,
+ );
+ }
+
+ // Validate program file exists
+ const programPath = path.resolve(program.path);
+ if (!fs.existsSync(programPath)) {
+ this.error(`Program file not found: ${programPath}`);
+ }
+ }
+ }
+
async run() {
const { flags } = await this.parse(SetupCommand);
const loader = new CustomLoader("Performing setup tasks...\n");
@@ -139,7 +194,24 @@ class SetupCommand extends Command {
});
this.log("\nTest validator stopped successfully \x1b[32m✔\x1b[0m");
} else {
+ const rawValues = flags["sbf-program"] || [];
+
+ if (rawValues.length % 2 !== 0) {
+ this.error("Each --sbf-program flag must have exactly two arguments");
+ }
+
+ const programs: { address: string; path: string }[] = [];
+ for (let i = 0; i < rawValues.length; i += 2) {
+ programs.push({
+ address: rawValues[i],
+ path: rawValues[i + 1],
+ });
+ }
+
+ this.validatePrograms(programs);
+
await initTestEnv({
+ additionalPrograms: programs,
checkPhotonVersion: !flags["relax-indexer-version-constraint"],
indexer: !flags["skip-indexer"],
limitLedgerSize: flags["limit-ledger-size"],
diff --git a/cli/src/utils/initTestEnv.ts b/cli/src/utils/initTestEnv.ts
index 51fd387bfc..635f5b4b04 100644
--- a/cli/src/utils/initTestEnv.ts
+++ b/cli/src/utils/initTestEnv.ts
@@ -20,6 +20,35 @@ import {
import { killProver, startProver } from "./processProverServer";
import { killIndexer, startIndexer } from "./processPhotonIndexer";
+type Program = { id: string; name?: string; tag?: string; path?: string };
+export const SYSTEM_PROGRAMS: Program[] = [
+ {
+ id: "noopb9bkMVfRPU8AsbpTUg8AQkHtKwMYZiFUjNRtMmV",
+ name: "spl_noop.so",
+ tag: SPL_NOOP_PROGRAM_TAG,
+ },
+ {
+ id: "SySTEM1eSU2p4BGQfQpimFEWWSC1XDFeun3Nqzz3rT7",
+ name: "light_system_program.so",
+ tag: LIGHT_SYSTEM_PROGRAM_TAG,
+ },
+ {
+ id: "cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m",
+ name: "light_compressed_token.so",
+ tag: LIGHT_COMPRESSED_TOKEN_TAG,
+ },
+ {
+ id: "compr6CUsB5m2jS4Y3831ztGSTnDpnKJTKS95d64XVq",
+ name: "account_compression.so",
+ tag: LIGHT_ACCOUNT_COMPRESSION_TAG,
+ },
+ {
+ id: "Lighton6oQpVkeewmo2mcPTQQp7kYHr4fWpAgJyEmDX",
+ name: "light_registry.so",
+ tag: LIGHT_REGISTRY_TAG,
+ },
+];
+
export async function stopTestEnv(options: {
indexer: boolean;
prover: boolean;
@@ -208,35 +237,8 @@ export async function getSolanaArgs({
gossipHost?: string;
downloadBinaries?: boolean;
}): Promise> {
- type Program = { id: string; name?: string; tag?: string; path?: string };
// TODO: adjust program tags
- const programs: Program[] = [
- {
- id: "noopb9bkMVfRPU8AsbpTUg8AQkHtKwMYZiFUjNRtMmV",
- name: "spl_noop.so",
- tag: SPL_NOOP_PROGRAM_TAG,
- },
- {
- id: "SySTEM1eSU2p4BGQfQpimFEWWSC1XDFeun3Nqzz3rT7",
- name: "light_system_program.so",
- tag: LIGHT_SYSTEM_PROGRAM_TAG,
- },
- {
- id: "cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m",
- name: "light_compressed_token.so",
- tag: LIGHT_COMPRESSED_TOKEN_TAG,
- },
- {
- id: "compr6CUsB5m2jS4Y3831ztGSTnDpnKJTKS95d64XVq",
- name: "account_compression.so",
- tag: LIGHT_ACCOUNT_COMPRESSION_TAG,
- },
- {
- id: "Lighton6oQpVkeewmo2mcPTQQp7kYHr4fWpAgJyEmDX",
- name: "light_registry.so",
- tag: LIGHT_REGISTRY_TAG,
- },
- ];
+ const programs: Program[] = [...SYSTEM_PROGRAMS];
if (additionalPrograms)
additionalPrograms.forEach((program) => {
programs.push({ id: program.address, path: program.path });
diff --git a/cli/src/utils/processPhotonIndexer.ts b/cli/src/utils/processPhotonIndexer.ts
index a064552740..8a875a0a11 100644
--- a/cli/src/utils/processPhotonIndexer.ts
+++ b/cli/src/utils/processPhotonIndexer.ts
@@ -21,16 +21,14 @@ export async function startIndexer(
throw new Error(message);
} else {
console.log("Starting indexer...");
- let args: string[] = [];
+ const args: string[] = [
+ "--port",
+ indexerPort.toString(),
+ "--rpc-url",
+ rpcUrl,
+ ];
if (photonDatabaseUrl) {
- args = [
- "--db-url",
- photonDatabaseUrl,
- "--port",
- indexerPort.toString(),
- "--rpc-url",
- rpcUrl,
- ];
+ args.push("--db-url", photonDatabaseUrl);
}
spawnBinary(INDEXER_PROCESS_NAME, args);
await waitForServers([{ port: indexerPort, path: "/getIndexerHealth" }]);
diff --git a/cli/test/commands/test-validator/index.test.ts b/cli/test/commands/test-validator/index.test.ts
new file mode 100644
index 0000000000..b2008ef79d
--- /dev/null
+++ b/cli/test/commands/test-validator/index.test.ts
@@ -0,0 +1,353 @@
+import { expect } from "@oclif/test";
+import { defaultSolanaWalletKeypair, programsDirPath } from "../../../src";
+import { Connection, Keypair } from "@solana/web3.js";
+import * as path from "path";
+import * as fs from "fs";
+import { killProcess } from "../../../src/utils/process";
+import { exec as execCb } from "child_process";
+import { promisify } from "util";
+import { ExecException } from "node:child_process";
+
+const exec = promisify(execCb);
+
+describe("test-validator command", function () {
+ this.timeout(120_000);
+
+ const defaultRpcPort = 8899;
+ const customRpcPort = 8877;
+ const defaultIndexerPort = 8784;
+ const customIndexerPort = 8755;
+ const defaultProverPort = 3001;
+ const customProverPort = 3002;
+
+ async function cleanupProcesses() {
+ console.log("Running cleanup...");
+ try {
+ // Stop the validator using CLI command first
+ try {
+ await exec("./test_bin/dev test-validator --stop");
+ console.log("Validator stopped via CLI command");
+ } catch (e) {
+ console.log("No running validator to stop via CLI");
+ }
+
+ // Then force kill any remaining processes
+ await killProcess("solana-test-validator");
+ await killProcess("photon");
+ await killProcess("prover");
+
+ // Wait for processes to fully terminate
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+
+ // Verify processes are actually stopped
+ try {
+ await fetch(`http://localhost:${defaultRpcPort}/health`);
+ throw new Error("Validator still running");
+ } catch (e) {
+ console.log("Validator stopped");
+ }
+
+ try {
+ await fetch(`http://localhost:${defaultIndexerPort}/health`);
+ throw new Error("Indexer still running");
+ } catch (e) {
+ console.log("Indexer stopped");
+ }
+
+ try {
+ await fetch(`http://localhost:${defaultProverPort}/health`);
+ throw new Error("Prover still running");
+ } catch (e) {
+ console.log("Prover stopped");
+ }
+
+ console.log("Cleanup completed successfully");
+ } catch (error) {
+ console.error("Error in cleanup:", error);
+ throw error;
+ }
+ }
+
+ before(async function () {
+ await cleanupProcesses();
+ });
+
+ afterEach(async function () {
+ await cleanupProcesses();
+ });
+
+ it("should start validator with default settings", async function () {
+ console.log("Starting test-validator...");
+
+ const { stdout } = await exec("./test_bin/dev test-validator");
+ console.log("Command output:", stdout);
+
+ expect(stdout).to.contain("Setup tasks completed successfully");
+ console.log("Stdout check passed");
+
+ console.log("Waiting for validator to be fully ready...");
+ await new Promise((resolve) => setTimeout(resolve, 5000));
+
+ console.log("Attempting to connect to validator...");
+ const connection = new Connection(`http://localhost:${defaultRpcPort}`, {
+ commitment: "confirmed",
+ confirmTransactionInitialTimeout: 10000,
+ });
+
+ try {
+ console.log("Getting validator version...");
+ const version = await connection.getVersion();
+ console.log("Validator version:", version);
+ expect(version).to.have.property("solana-core");
+
+ console.log("Getting wallet balance...");
+ const payer = defaultSolanaWalletKeypair();
+ const balance = await connection.getBalance(payer.publicKey);
+ console.log("Wallet balance:", balance);
+ expect(balance).to.be.at.least(0);
+
+ console.log("Test completed successfully");
+ } catch (error) {
+ console.error("Error during validator checks:", error);
+ throw error;
+ }
+ });
+
+ it("should start validator with custom ports", async function () {
+ const command = [
+ "./test_bin/dev test-validator",
+ `--rpc-port ${customRpcPort}`,
+ `--indexer-port ${customIndexerPort}`,
+ `--prover-port ${customProverPort}`,
+ ].join(" ");
+
+ const { stdout } = await exec(command);
+ expect(stdout).to.contain("Setup tasks completed successfully");
+
+ await new Promise((resolve) => setTimeout(resolve, 5000));
+
+ // Verify validator on custom port
+ const connection = new Connection(`http://localhost:${customRpcPort}`);
+ const version = await connection.getVersion();
+ expect(version).to.have.property("solana-core");
+
+ // Verify indexer on custom port
+ const indexerResponse = await fetch(
+ `http://localhost:${customIndexerPort}/health`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ },
+ );
+ expect(indexerResponse.status).to.equal(200);
+
+ // Verify prover on custom port
+ const proverResponse = await fetch(
+ `http://localhost:${customProverPort}/health`,
+ );
+ expect(proverResponse.status).to.equal(200);
+ });
+
+ it("should start validator without indexer and prover", async function () {
+ const { stdout } = await exec(
+ "./test_bin/dev test-validator --skip-indexer --skip-prover",
+ );
+ expect(stdout).to.contain("Setup tasks completed successfully");
+
+ await new Promise((resolve) => setTimeout(resolve, 5000));
+
+ // Verify validator is running
+ const connection = new Connection(`http://localhost:${defaultRpcPort}`);
+ const version = await connection.getVersion();
+ expect(version).to.have.property("solana-core");
+
+ // Verify indexer is not running
+ try {
+ await fetch(`http://localhost:${defaultIndexerPort}/health`);
+ throw new Error("Indexer should not be running");
+ } catch (error) {
+ expect(error).to.exist;
+ }
+
+ // Verify prover is not running
+ try {
+ await fetch(`http://localhost:${defaultProverPort}/health`);
+ throw new Error("Prover should not be running");
+ } catch (error) {
+ expect(error).to.exist;
+ }
+ });
+
+ it("should fail with invalid geyser config path", async function () {
+ try {
+ await exec(
+ "./test_bin/dev test-validator --geyser-config nonexistent.json",
+ );
+ throw new Error("Should have failed");
+ } catch (error) {
+ const execError = error as ExecException & {
+ stdout?: string;
+ stderr?: string;
+ };
+ // Check either error message or stderr
+ const errorText = execError.message || execError.stderr || "";
+ expect(errorText).to.contain("Geyser config file not found");
+ }
+ });
+
+ it("should start and stop validator with custom arguments", async function () {
+ const startCommand =
+ './test_bin/dev test-validator --validator-args "--log-messages-bytes-limit 1000"';
+ const { stdout: startOutput } = await exec(startCommand);
+ expect(startOutput).to.contain("Setup tasks completed successfully");
+
+ await new Promise((resolve) => setTimeout(resolve, 5000));
+
+ // Verify validator is running
+ const connection = new Connection(`http://localhost:${defaultRpcPort}`);
+ const version = await connection.getVersion();
+ expect(version).to.have.property("solana-core");
+
+ // Stop validator
+ const stopCommand = "./test_bin/dev test-validator --stop";
+ const { stdout: stopOutput } = await exec(stopCommand);
+ expect(stopOutput).to.contain("Test validator stopped successfully");
+
+ // Verify validator is stopped
+ try {
+ await connection.getVersion();
+ throw new Error("Validator should be stopped");
+ } catch (error) {
+ expect(error).to.exist;
+ }
+ });
+
+ const SYSTEM_PROGRAM_ID = "noopb9bkMVfRPU8AsbpTUg8AQkHtKwMYZiFUjNRtMmV";
+ const SYSTEM_PROGRAM_NAME = "spl_noop.so";
+
+ it("should fail when deploying program with system program address", async function () {
+ const testProgramPath = path.join(programsDirPath(), "test_program.so");
+ fs.writeFileSync(testProgramPath, "dummy program data");
+
+ try {
+ const command = [
+ "./test_bin/dev test-validator",
+ "--sbf-program",
+ SYSTEM_PROGRAM_ID, // Use system program address
+ testProgramPath,
+ ].join(" ");
+
+ await exec(command);
+ throw new Error(
+ "Should have failed due to system program address collision",
+ );
+ } catch (error) {
+ const execError = error as ExecException & {
+ stdout?: string;
+ stderr?: string;
+ };
+ const errorText = execError.message || execError.stderr || "";
+ expect(errorText).to.contain("collides with system program");
+ expect(errorText).to.contain(SYSTEM_PROGRAM_ID);
+ } finally {
+ fs.unlinkSync(testProgramPath);
+ }
+ });
+
+ it("should fail when deploying program with system program name", async function () {
+ const testProgramPath = path.join(programsDirPath(), SYSTEM_PROGRAM_NAME);
+ fs.writeFileSync(testProgramPath, "dummy program data");
+
+ try {
+ const testKeypair = Keypair.generate();
+ const command = [
+ "./test_bin/dev test-validator",
+ "--sbf-program",
+ testKeypair.publicKey.toString(),
+ testProgramPath,
+ ].join(" ");
+
+ await exec(command);
+ throw new Error(
+ "Should have failed due to system program name collision",
+ );
+ } catch (error) {
+ const execError = error as ExecException & {
+ stdout?: string;
+ stderr?: string;
+ };
+ const errorText = execError.message || execError.stderr || "";
+ expect(errorText).to.contain("collides with system program");
+ expect(errorText).to.contain(SYSTEM_PROGRAM_NAME);
+ } finally {
+ fs.unlinkSync(testProgramPath);
+ }
+ });
+
+ it("should fail when deploying multiple programs with same address", async function () {
+ const testProgramPath1 = path.join(programsDirPath(), "test_program1.so");
+ const testProgramPath2 = path.join(programsDirPath(), "test_program2.so");
+ fs.writeFileSync(testProgramPath1, "dummy program data 1");
+ fs.writeFileSync(testProgramPath2, "dummy program data 2");
+
+ try {
+ const testKeypair = Keypair.generate();
+ const command = [
+ "./test_bin/dev test-validator",
+ "--sbf-program",
+ testKeypair.publicKey.toString(),
+ testProgramPath1,
+ "--sbf-program",
+ testKeypair.publicKey.toString(), // Same address as first program
+ testProgramPath2,
+ ].join(" ");
+
+ await exec(command);
+ throw new Error("Should have failed due to duplicate program address");
+ } catch (error) {
+ const execError = error as ExecException & {
+ stdout?: string;
+ stderr?: string;
+ };
+ const errorText = execError.message || execError.stderr || "";
+ expect(errorText).to.contain("Duplicate program address detected");
+ } finally {
+ fs.unlinkSync(testProgramPath1);
+ fs.unlinkSync(testProgramPath2);
+ }
+ });
+
+ it("should succeed with valid program deployment avoiding system collisions", async function () {
+ const testProgramPath = path.join(programsDirPath(), "custom_program.so");
+ fs.writeFileSync(testProgramPath, "dummy program data");
+
+ try {
+ const testKeypair = Keypair.generate();
+ const command = [
+ "./test_bin/dev test-validator",
+ "--sbf-program",
+ testKeypair.publicKey.toString(),
+ testProgramPath,
+ ].join(" ");
+
+ const { stdout } = await exec(command);
+ expect(stdout).to.contain("Setup tasks completed successfully");
+
+ await new Promise((resolve) => setTimeout(resolve, 5000));
+
+ // Verify validator is running
+ const connection = new Connection(`http://localhost:${defaultRpcPort}`);
+ const version = await connection.getVersion();
+ expect(version).to.have.property("solana-core");
+
+ let programAccountInfo = await connection.getAccountInfo(
+ testKeypair.publicKey,
+ );
+ expect(programAccountInfo).to.exist;
+ expect(programAccountInfo!.executable).to.be.true;
+ } finally {
+ fs.unlinkSync(testProgramPath);
+ }
+ });
+});