diff --git a/src/commands/network/index.ts b/src/commands/network/index.ts index 4e786e0a..dda8f74c 100644 --- a/src/commands/network/index.ts +++ b/src/commands/network/index.ts @@ -22,6 +22,7 @@ export function initializeNetworkCommands(program: Command) { .option("--rounds-storage ", "RoundsStorage contract address override") .option("--appeals ", "Appeals contract address override") .option("--chain-id ", "Chain ID override") + .option("--explorer ", "Block explorer URL for this custom network (custom networks do NOT inherit the base's explorer, to avoid misleading links)") .action((alias: string, options) => networkActions.addNetwork(alias, options)); // genlayer network set [name] diff --git a/src/commands/network/setNetwork.ts b/src/commands/network/setNetwork.ts index de771cb8..3f141e93 100644 --- a/src/commands/network/setNetwork.ts +++ b/src/commands/network/setNetwork.ts @@ -45,6 +45,7 @@ export interface AddNetworkOptions { roundsStorage?: string; appeals?: string; chainId?: string; + explorer?: string; } type NetworkEntry = @@ -174,10 +175,11 @@ export class NetworkActions extends BaseAction { options.deployment || options.rpc || options.chainId !== undefined || + options.explorer || CONTRACT_FLAG_OPTIONS.some(option => Boolean(options[option.optionKey])), ); if (!hasOverrideInput) { - throw new Error("Provide at least one override: --deployment, --rpc, --chain-id, or a contract address flag"); + throw new Error("Provide at least one override: --deployment, --rpc, --chain-id, --explorer, or a contract address flag"); } const overrides: CustomNetworkOverrides = {}; @@ -210,6 +212,14 @@ export class NetworkActions extends BaseAction { overrides.chainId = chainId; } + if (options.explorer) { + const url = options.explorer.trim(); + if (!/^https?:\/\//i.test(url)) { + throw new Error(`Invalid --explorer URL (must start with http:// or https://): ${options.explorer}`); + } + overrides.explorer = url; + } + return { base: options.base, overrides, diff --git a/src/lib/networks/customNetworks.ts b/src/lib/networks/customNetworks.ts index d3f815fb..d8c624b3 100644 --- a/src/lib/networks/customNetworks.ts +++ b/src/lib/networks/customNetworks.ts @@ -21,6 +21,8 @@ export interface CustomNetworkOverrides { feeManager?: Address; roundsStorage?: Address; appeals?: Address; + /** Block explorer URL for this custom network. Custom profiles do NOT inherit the base chain's explorer (see applyCustomNetworkProfile). */ + explorer?: string; } export interface CustomNetworkProfile { @@ -97,7 +99,9 @@ export function parseDeploymentObject(input: unknown, deploymentKey?: string): P } const found: Record = {}; - walkDeploymentObject(selected, [], found); + // Guarded above (non-null, object, non-array); narrow from `object` to the + // record shape walkDeploymentObject expects. + walkDeploymentObject(selected as Record, [], found); for (const [contractName, entries] of Object.entries(found)) { if (entries.length > 1) { @@ -179,6 +183,19 @@ export function applyCustomNetworkProfile( }; } + // Custom profiles do NOT inherit the base chain's block explorer. The base's + // explorer indexes the BASE deployment, so surfacing it for a custom network + // is misleading — e.g. a pre-clarke profile based on bradbury would show + // explorer-bradbury links that never indexed its transactions. Show an + // explorer only when the operator set one explicitly via --explorer. + if (overrides.explorer) { + (chain as any).blockExplorers = { + default: {name: "Explorer", url: overrides.explorer}, + }; + } else { + delete (chain as any).blockExplorers; + } + return chain; } diff --git a/tests/actions/customNetworkProfiles.test.ts b/tests/actions/customNetworkProfiles.test.ts index 880af1fb..be3189a5 100644 --- a/tests/actions/customNetworkProfiles.test.ts +++ b/tests/actions/customNetworkProfiles.test.ts @@ -70,6 +70,48 @@ describe("custom network profiles", () => { ); }); + test("network add stores and applies an --explorer override", async () => { + await action.addNetwork("bradbury-explorer", { + base: "testnet-bradbury", + rpc: "http://localhost:9999", + explorer: "https://explorer.custom.example/", + }); + + expect(failSpy).not.toHaveBeenCalled(); + expect(readConfig().customNetworks["bradbury-explorer"].overrides.explorer).toBe( + "https://explorer.custom.example/", + ); + const chain = resolveNetwork("bradbury-explorer", readConfig().customNetworks); + expect(chain.blockExplorers?.default?.url).toBe("https://explorer.custom.example/"); + }); + + test("custom network does NOT inherit the base block explorer when --explorer is omitted", async () => { + // Guard the premise: the base chain does carry an explorer. + expect(testnetBradbury.blockExplorers?.default?.url).toBeTruthy(); + + await action.addNetwork("bradbury-no-explorer", { + base: "testnet-bradbury", + rpc: "http://localhost:9999", + }); + + expect(failSpy).not.toHaveBeenCalled(); + const chain = resolveNetwork("bradbury-no-explorer", readConfig().customNetworks); + // The misleading base explorer must NOT be inherited. + expect(chain.blockExplorers).toBeUndefined(); + }); + + test("network add rejects an invalid --explorer URL", async () => { + await action.addNetwork("bradbury-bad-explorer", { + base: "testnet-bradbury", + explorer: "explorer.custom.example", + }); + + expect(failSpy).toHaveBeenCalledWith( + "Failed to add custom network profile", + expect.stringContaining("Invalid --explorer URL"), + ); + }); + test("network add sources overrides from a deployment file", async () => { const deploymentPath = writeDeployment({ genlayerTestnet: { @@ -145,7 +187,7 @@ describe("custom network profiles", () => { expect(failSpy).toHaveBeenNthCalledWith( 4, "Failed to add custom network profile", - "Provide at least one override: --deployment, --rpc, --chain-id, or a contract address flag", + "Provide at least one override: --deployment, --rpc, --chain-id, --explorer, or a contract address flag", ); }); @@ -268,7 +310,7 @@ describe("custom network profiles", () => { test("StakingAction.getNetwork accepts a custom alias", () => { const stakingAction = new StakingAction(); - vi.spyOn(stakingAction as any, "getConfigByKey").mockImplementation((key: string) => { + (vi.spyOn(stakingAction as any, "getConfigByKey") as any).mockImplementation((key: string) => { if (key === "customNetworks") { return { "bradbury-clarke": {