diff --git a/src/commands/staking/index.ts b/src/commands/staking/index.ts index 6cc95dac..fe49e56d 100644 --- a/src/commands/staking/index.ts +++ b/src/commands/staking/index.ts @@ -23,12 +23,32 @@ export function initializeStakingCommands(program: Command) { addWalletModeOption( staking .command("wizard") - .description("Interactive wizard to become a validator: funds the stake from your wallet or a vesting contract, and signs with a keystore key or a browser wallet (--wallet browser)") + .description("Interactive wizard to become a validator: funds the stake from your wallet or a vesting contract, and signs with a keystore key or a browser wallet (--wallet browser). Every prompt can be supplied by a flag; pass --non-interactive to run scripted with zero prompts") .option("--account ", "Account to use (skip selection)") .option("--network ", "Network to use (skip selection)") .option("--skip-identity", "Skip identity setup step") .option("--rpc ", "RPC URL for the network") - .option("--staking-address
", "Staking contract address (overrides chain config)"), + .option("--staking-address
", "Staking contract address (overrides chain config)") + // Non-interactive / scriptable mode + .option("--non-interactive", "Run end-to-end with no prompts; every choice must come from a flag") + .option("--yes", "Alias for --non-interactive (assume yes to confirmations)") + .option("--funding-source ", "Where the self-stake is funded from: 'wallet' (default) or 'vesting'") + .option("--vesting-contract
", "Vesting contract to fund from (with --funding-source vesting)") + .option("--operator
", "External operator address (0x...)") + .option("--create-operator ", "Create a new operator account and export its keystore") + .option("--operator-same", "Use the owner address as the operator") + .option("--operator-password ", "Password for the exported operator keystore (with --create-operator)") + .option("--operator-keystore-out ", "Output filename for the exported operator keystore") + .option("--amount ", "Self-stake amount (GEN, e.g. '42' or '42gen')") + // Identity metadata (mirrors `staking set-identity`); --moniker enables the identity step + .option("--moniker ", "Validator display name (enables the identity step)") + .option("--logo-uri ", "Logo URI") + .option("--website ", "Website URL") + .option("--description ", "Description") + .option("--email ", "Contact email") + .option("--twitter ", "Twitter handle") + .option("--telegram ", "Telegram handle") + .option("--github ", "GitHub handle"), ).action(async (options: WizardOptions) => { const wizard = new ValidatorWizardAction(); await wizard.execute(options); diff --git a/src/commands/staking/wizard.ts b/src/commands/staking/wizard.ts index 91d970da..1f0318f7 100644 --- a/src/commands/staking/wizard.ts +++ b/src/commands/staking/wizard.ts @@ -17,6 +17,35 @@ const BROWSER_WALLET_CHOICE = "__browser_wallet__"; export interface WizardOptions extends StakingConfig { skipIdentity?: boolean; + /** Run end-to-end with zero prompts; every choice must come from a flag. */ + nonInteractive?: boolean; + /** Alias for --non-interactive (also doubles as "assume yes" to confirmations). */ + yes?: boolean; + /** Funding source: "wallet" (default) or "vesting". */ + fundingSource?: string; + /** Vesting contract address to fund from (when fundingSource === "vesting"). */ + vestingContract?: string; + /** External operator address (0x...). */ + operator?: string; + /** Name of a new operator account to create (exported keystore). */ + createOperator?: string; + /** Reuse the owner address as the operator. */ + operatorSame?: boolean; + /** Password for the exported operator keystore (required with --create-operator). */ + operatorPassword?: string; + /** Output filename for the exported operator keystore. */ + operatorKeystoreOut?: string; + /** Self-stake amount (GEN number, e.g. "42" or "42gen"). */ + amount?: string; + // Identity fields (mirror `staking set-identity`). + moniker?: string; + logoUri?: string; + website?: string; + description?: string; + email?: string; + twitter?: string; + telegram?: string; + github?: string; } interface WizardState { @@ -54,15 +83,33 @@ function ensureHexPrefix(address: string): string { } export class ValidatorWizardAction extends StakingAction { + /** Non-interactive mode: no prompts, every choice comes from a flag. */ + private ni = false; + constructor() { super(); } + /** True when the wizard must run with zero prompts (--non-interactive / --yes). */ + private isNonInteractive(options: WizardOptions): boolean { + return Boolean(options.nonInteractive || options.yes); + } + + /** Fail with a clear "you forgot flag X" message for non-interactive mode. */ + private missingFlag(flag: string, why?: string): never { + throw new Error( + `Non-interactive mode requires ${flag}${why ? ` (${why})` : ""}. ` + + `Pass it, or drop --non-interactive/--yes to be prompted.`, + ); + } + async execute(options: WizardOptions): Promise { console.log("\n========================================"); console.log(" GenLayer Validator Setup Wizard"); console.log("========================================\n"); + this.ni = this.isNonInteractive(options); + // Validate flag combinations up-front (throws on --account/--password + browser). this.assertBrowserWalletFlags(options, "wizard"); @@ -83,10 +130,10 @@ export class ValidatorWizardAction extends StakingAction { await this.stepBalanceCheck(state, options); // Step 4: Operator Setup - await this.stepOperatorSetup(state); + await this.stepOperatorSetup(state, options); // Step 5: Stake Amount - await this.stepStakeAmount(state); + await this.stepStakeAmount(state, options); // Step 6: Join as Validator await this.stepJoinValidator(state, options); @@ -169,6 +216,12 @@ export class ValidatorWizardAction extends StakingAction { return; } + // Non-interactive: the owner must be resolvable from flags. Browser and + // --account both returned above, so reaching here means neither was given. + if (this.ni) { + this.missingFlag("--account", "to select the owner keystore, or --wallet browser"); + } + const accounts = this.listAccounts(); if (accounts.length === 0) { @@ -266,6 +319,10 @@ export class ValidatorWizardAction extends StakingAction { return; } + if (this.ni) { + this.missingFlag("--network"); + } + const currentNetwork = this.getConfigByKey("network"); // Exclude studionet - not compatible with staking const excludedNetworks = ["studionet"]; @@ -325,6 +382,10 @@ export class ValidatorWizardAction extends StakingAction { console.log("Step: Funding Source"); console.log("--------------------\n"); + if (this.ni) { + return this.stepStakeSourceNonInteractive(state, options); + } + // Loop so a "no vesting contracts" answer can bounce the user straight back // to the source choice instead of crashing or dead-ending. for (;;) { @@ -397,6 +458,68 @@ export class ValidatorWizardAction extends StakingAction { } } + /** + * Non-interactive funding source. Defaults to "wallet" (the original flow) when + * --funding-source is omitted. For "vesting" the concrete contract is taken from + * --vesting-contract, or auto-resolved when the owner has exactly one; zero or + * many (without --vesting-contract) is a hard error naming the flag to pass. + */ + private async stepStakeSourceNonInteractive( + state: Partial, + options: WizardOptions, + ): Promise { + const source = options.fundingSource ?? "wallet"; + if (source !== "wallet" && source !== "vesting") { + throw new Error(`Invalid --funding-source '${source}'. Use 'wallet' or 'vesting'.`); + } + + if (source === "wallet") { + state.stakeSource = "wallet"; + console.log("Funding source: your wallet\n"); + return; + } + + // Vesting: the beneficiary is the owner. For a browser owner not yet + // connected, start the shared session now so we can read the address. + let beneficiary = state.accountAddress; + if (!beneficiary && state.ownerIsBrowserWallet) { + const session = await this.ensureBrowserSession(state, options); + beneficiary = session.signerAddress; + } + + if (options.vestingContract) { + state.vestingContract = ensureHexPrefix(options.vestingContract); + } else { + this.startSpinner("Looking up vesting contracts..."); + let vestings: Address[] = []; + try { + const readClient = this.getWizardVestingReadClient(state, options); + vestings = await readClient.getBeneficiaryVestings(beneficiary as Address); + } catch (error: any) { + this.stopSpinner(); + throw new Error(`Could not look up vesting contracts: ${error.message || error}`); + } + this.stopSpinner(); + + if (!vestings || vestings.length === 0) { + throw new Error( + `No vesting contracts found for ${beneficiary}. ` + + `Fund a vesting contract first, or use --funding-source wallet.`, + ); + } + if (vestings.length > 1) { + this.missingFlag( + "--vesting-contract", + `${vestings.length} vesting contracts found for ${beneficiary}; pick one`, + ); + } + state.vestingContract = ensureHexPrefix(vestings[0]); + } + + state.stakeSource = "vesting"; + console.log(`Funding from vesting contract: ${state.vestingContract}\n`); + } + private async stepBalanceCheck(state: Partial, options: WizardOptions): Promise { console.log("Step 3: Balance Check"); console.log("---------------------\n"); @@ -554,10 +677,14 @@ export class ValidatorWizardAction extends StakingAction { console.log("Vesting balance sufficient!\n"); } - private async stepOperatorSetup(state: Partial): Promise { + private async stepOperatorSetup(state: Partial, options: WizardOptions): Promise { console.log("Step 4: Operator Setup"); console.log("----------------------\n"); + if (this.ni) { + return this.stepOperatorSetupNonInteractive(state, options); + } + console.log("Using a separate operator address is recommended for security:"); console.log("- Owner account: holds staked funds (keep secure)"); console.log("- Operator account: signs blocks (hot wallet on validator server)\n"); @@ -815,7 +942,84 @@ export class ValidatorWizardAction extends StakingAction { console.log("========================================\n"); } - private async stepStakeAmount(state: Partial): Promise { + /** + * Non-interactive operator setup. Exactly one of the operator flags must be + * given: --operator-same (reuse owner), --operator (external), or + * --create-operator (mint + export a new keystore, needs + * --operator-password). Anything else is a hard error naming the choices. + */ + private async stepOperatorSetupNonInteractive( + state: Partial, + options: WizardOptions, + ): Promise { + if (options.operatorSame) { + state.operatorAddress = ensureHexPrefix(state.accountAddress!); + state.operatorAccountName = state.accountName; + console.log("Operator will be the same as owner address.\n"); + return; + } + + if (options.operator) { + if (!options.operator.match(/^0x[a-fA-F0-9]{40}$/)) { + throw new Error( + `Invalid --operator '${options.operator}'. Expected 0x followed by 40 hex characters.`, + ); + } + state.operatorAddress = ensureHexPrefix(options.operator); + console.log(`Operator: ${state.operatorAddress}\n`); + return; + } + + if (options.createOperator) { + const operatorName = options.createOperator; + if (this.listAccounts().find(a => a.name === operatorName)) { + throw new Error(`Account '${operatorName}' already exists. Choose another --create-operator name.`); + } + if (!options.operatorPassword) { + this.missingFlag("--operator-password", "to encrypt the exported operator keystore"); + } + if (options.operatorPassword.length < 8) { + throw new Error("--operator-password must be at least 8 characters."); + } + + const createAction = new CreateAccountAction(); + await createAction.execute({name: operatorName, overwrite: false, setActive: false}); + + const operatorKeystorePath = this.getKeystorePath(operatorName); + const operatorData = JSON.parse(readFileSync(operatorKeystorePath, "utf-8")); + state.operatorAddress = ensureHexPrefix(operatorData.address); + state.operatorAccountName = operatorName; + + const outputFilename = options.operatorKeystoreOut || `${operatorName}-keystore.json`; + const outputPath = path.resolve(`./${outputFilename}`); + + const exportAction = new ExportAccountAction(); + await exportAction.execute({ + account: operatorName, + output: outputPath, + password: options.operatorPassword, + overwrite: true, + }); + + state.operatorKeystorePath = outputPath; + + console.log("\n========================================"); + console.log(" IMPORTANT: Transfer operator keystore"); + console.log("========================================"); + console.log(`File: ${outputPath}`); + console.log("Transfer this file to your validator server and import it"); + console.log("into your validator node software."); + console.log("========================================\n"); + return; + } + + this.missingFlag( + "an operator choice", + "one of --operator-same, --operator , or --create-operator ", + ); + } + + private async stepStakeAmount(state: Partial, options: WizardOptions): Promise { console.log("Step 5: Stake Amount"); console.log("--------------------\n"); @@ -823,6 +1027,27 @@ export class ValidatorWizardAction extends StakingAction { const minStakeGEN = formatEther(state.minStake!); const hasMinStake = state.minStake! > 0n; + if (this.ni) { + if (!options.amount) { + this.missingFlag("--amount"); + } + const cleaned = options.amount.toLowerCase().replace("gen", "").trim(); + const num = parseFloat(cleaned); + if (isNaN(num) || num <= 0) { + throw new Error(`Invalid --amount '${options.amount}'. Enter a positive GEN amount.`); + } + const amountWei = BigInt(Math.floor(num * 1e18)); + if (hasMinStake && amountWei < state.minStake!) { + throw new Error(`--amount is below the minimum stake of ${minStakeGEN} GEN.`); + } + if (amountWei > state.balance!) { + throw new Error(`--amount exceeds the available balance (${balanceGEN} GEN).`); + } + state.stakeAmount = options.amount.toLowerCase().endsWith("gen") ? options.amount : `${options.amount}gen`; + console.log(`Staking ${state.stakeAmount}\n`); + return; + } + const {stakeAmount} = await inquirer.prompt([ { type: "input", @@ -1089,6 +1314,28 @@ export class ValidatorWizardAction extends StakingAction { return; } + if (this.ni) { + // Identity is optional non-interactively: driven by --moniker. No moniker + // means "skip identity" (same as the interactive "no" answer). + if (!options.moniker) { + console.log("\nNo --moniker given; skipping identity setup."); + console.log("You can set it later with: genlayer staking set-identity\n"); + return; + } + state.identity = { + moniker: options.moniker, + logoUri: options.logoUri || undefined, + website: options.website || undefined, + description: options.description || undefined, + email: options.email || undefined, + twitter: options.twitter || undefined, + telegram: options.telegram || undefined, + github: options.github || undefined, + }; + await this.commitIdentity(state, options); + return; + } + const {setupIdentity} = await inquirer.prompt([ { type: "confirm", @@ -1180,6 +1427,17 @@ export class ValidatorWizardAction extends StakingAction { github: github || undefined, }; + await this.commitIdentity(state, options); + } + + /** + * Send the set-identity transaction from `state.identity` — via the browser + * bridge for a browser owner, otherwise the keystore staking client. Shared by + * the interactive and non-interactive identity steps so both behave identically. + */ + private async commitIdentity(state: Partial, options: WizardOptions): Promise { + const identity = state.identity!; + this.startSpinner("Setting validator identity..."); // Use the validator wallet address (contract), not owner address @@ -1190,16 +1448,16 @@ export class ValidatorWizardAction extends StakingAction { const session = await this.ensureBrowserSession(state, options); this.setSpinnerText("Confirm the identity transaction in your browser wallet..."); const {to, data} = buildSetIdentityTx(validatorAddress, { - moniker, - logoUri: logoUri || undefined, - website: website || undefined, - description: description || undefined, - email: email || undefined, - twitter: twitter || undefined, - telegram: telegram || undefined, - github: github || undefined, + moniker: identity.moniker, + logoUri: identity.logoUri, + website: identity.website, + description: identity.description, + email: identity.email, + twitter: identity.twitter, + telegram: identity.telegram, + github: identity.github, }); - await session.sendTransaction({to, data, label: `Set validator identity (${moniker})`}); + await session.sendTransaction({to, data, label: `Set validator identity (${identity.moniker})`}); } else { const client = await this.getStakingClient({ ...options, @@ -1209,14 +1467,14 @@ export class ValidatorWizardAction extends StakingAction { await client.setIdentity({ validator: validatorAddress as Address, - moniker, - logoUri: logoUri || undefined, - website: website || undefined, - description: description || undefined, - email: email || undefined, - twitter: twitter || undefined, - telegram: telegram || undefined, - github: github || undefined, + moniker: identity.moniker, + logoUri: identity.logoUri, + website: identity.website, + description: identity.description, + email: identity.email, + twitter: identity.twitter, + telegram: identity.telegram, + github: identity.github, }); } diff --git a/tests/actions/stakingWizard.test.ts b/tests/actions/stakingWizard.test.ts index 3831f1d6..53803ec9 100644 --- a/tests/actions/stakingWizard.test.ts +++ b/tests/actions/stakingWizard.test.ts @@ -415,3 +415,290 @@ describe("ValidatorWizardAction stake source (keystore owner)", () => { expect(validatorJoin).not.toHaveBeenCalled(); }); }); + +describe("ValidatorWizardAction --non-interactive (keystore owner)", () => { + let action: ValidatorWizardAction; + let validatorJoin: ReturnType; + let vestingValidatorJoin: ReturnType; + let setIdentity: ReturnType; + let getStakingClientSpy: any; + let getBrowserWalletSessionSpy: any; + + beforeEach(() => { + vi.clearAllMocks(); + action = new ValidatorWizardAction(); + + for (const m of [ + "startSpinner", + "setSpinnerText", + "succeedSpinner", + "failSpinner", + "stopSpinner", + "logInfo", + "logWarning", + "logError", + "log", + ]) { + vi.spyOn(action as any, m).mockImplementation(() => {}); + } + + vi.spyOn(action as any, "getCustomNetworks").mockReturnValue({}); + vi.spyOn(action as any, "getConfigByKey").mockReturnValue("testnet-bradbury"); + vi.spyOn(action as any, "writeConfig").mockImplementation(() => {}); + + vi.spyOn(action as any, "accountExists").mockReturnValue(true); + vi.spyOn(action as any, "getKeystorePath").mockReturnValue("/tmp/owner-keystore.json"); + vi.spyOn(action as any, "getSignerAddress").mockResolvedValue("0xOwner"); + vi.spyOn(action as any, "listAccounts").mockReturnValue([]); + + // The browser bridge must never start in keystore mode. + getBrowserWalletSessionSpy = vi.spyOn(action as any, "getBrowserWalletSession").mockImplementation(() => { + throw new Error("browser session must not start in keystore mode"); + }); + + validatorJoin = vi.fn().mockResolvedValue({ + validatorWallet: "0xWalletFromJoin", + transactionHash: "0xJoinTx", + amount: "42 GEN", + operator: "0xOperatorExternal", + blockNumber: 11n, + }); + vestingValidatorJoin = vi.fn().mockResolvedValue({ + validatorWallet: "0xVWalletCreated", + transactionHash: "0xVJoinTx", + operator: "0xOwner", + amount: "42 GEN", + blockNumber: 12n, + }); + setIdentity = vi.fn().mockResolvedValue({transactionHash: "0xIdTx"}); + getStakingClientSpy = vi.spyOn(action as any, "getStakingClient").mockResolvedValue({ + validatorJoin, + vestingValidatorJoin, + setIdentity, + getValidatorWallets: vi.fn().mockResolvedValue(["0xVWalletCreated"]), + } as any); + + vi.mocked(CreateAccountAction.prototype.execute).mockResolvedValue(undefined as any); + vi.mocked(ExportAccountAction.prototype.execute).mockResolvedValue(undefined as any); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const EXTERNAL_OP = "0x1111111111111111111111111111111111111111"; + + const run = (extra: Record = {}) => + action.execute({ + account: "owner", + wallet: "keystore", + network: "testnet-bradbury", + nonInteractive: true, + ...extra, + } as any); + + test("wallet source + external operator + amount + identity: full run with ZERO prompts", async () => { + await run({operator: EXTERNAL_OP, amount: "50gen", moniker: "MyValidator", website: "https://v.io"}); + + // No prompt was ever shown. + expect(inquirer.prompt).not.toHaveBeenCalled(); + + // Joined from the wallet with the external operator. + expect(validatorJoin).toHaveBeenCalledWith({amount: 50n * 10n ** 18n, operator: EXTERNAL_OP}); + expect(vestingValidatorJoin).not.toHaveBeenCalled(); + expect(getBrowserWalletSessionSpy).not.toHaveBeenCalled(); + + // Identity was applied from --moniker/--website against the validator wallet. + expect(setIdentity).toHaveBeenCalledWith( + expect.objectContaining({ + validator: "0xWalletFromJoin", + moniker: "MyValidator", + website: "https://v.io", + }), + ); + expect(action["succeedSpinner"]).toHaveBeenCalledWith( + "Validator created successfully!", + expect.objectContaining({validatorWallet: "0xWalletFromJoin"}), + ); + }); + + test("--yes is an alias for --non-interactive", async () => { + await action.execute({ + account: "owner", + wallet: "keystore", + network: "testnet-bradbury", + yes: true, + operatorSame: true, + amount: "50gen", + } as any); + + expect(inquirer.prompt).not.toHaveBeenCalled(); + // --operator-same reuses the owner address. + expect(validatorJoin).toHaveBeenCalledWith({amount: 50n * 10n ** 18n, operator: "0xOwner"}); + }); + + test("no --moniker: identity step is skipped (no setIdentity), still zero prompts", async () => { + await run({operatorSame: true, amount: "50gen"}); + + expect(inquirer.prompt).not.toHaveBeenCalled(); + expect(setIdentity).not.toHaveBeenCalled(); + expect(validatorJoin).toHaveBeenCalledOnce(); + }); + + test("vesting source with --vesting-contract: uses vestingValidatorJoin, no lookup prompt", async () => { + await run({ + fundingSource: "vesting", + vestingContract: "0xVesting", + operator: EXTERNAL_OP, + amount: "50gen", + }); + + expect(inquirer.prompt).not.toHaveBeenCalled(); + // Explicit contract given → no beneficiary lookup needed. + expect(mockGlClient.getBeneficiaryVestings).not.toHaveBeenCalled(); + expect(vestingValidatorJoin).toHaveBeenCalledWith({ + vesting: "0xVesting", + operator: EXTERNAL_OP, + amount: 50n * 10n ** 18n, + }); + expect(validatorJoin).not.toHaveBeenCalled(); + }); + + test("vesting source without --vesting-contract auto-resolves the single contract", async () => { + mockGlClient.getBeneficiaryVestings.mockResolvedValue(["0xOnlyVesting"]); + await run({fundingSource: "vesting", operatorSame: true, amount: "50gen"}); + + expect(inquirer.prompt).not.toHaveBeenCalled(); + expect(mockGlClient.getBeneficiaryVestings).toHaveBeenCalledWith("0xOwner"); + expect(vestingValidatorJoin).toHaveBeenCalledWith({ + vesting: "0xOnlyVesting", + operator: "0xOwner", + amount: 50n * 10n ** 18n, + }); + }); + + test("missing --amount fails clearly naming the flag", async () => { + await run({operatorSame: true}); + + expect(validatorJoin).not.toHaveBeenCalled(); + expect(action["failSpinner"]).toHaveBeenCalledWith( + "Wizard failed", + expect.stringMatching(/--amount/), + ); + }); + + test("missing operator choice fails clearly", async () => { + await run({amount: "50gen"}); + + expect(validatorJoin).not.toHaveBeenCalled(); + expect(action["failSpinner"]).toHaveBeenCalledWith( + "Wizard failed", + expect.stringMatching(/--operator/), + ); + }); + + test("missing owner (no --account, no browser) fails naming --account", async () => { + await action.execute({ + wallet: "keystore", + network: "testnet-bradbury", + nonInteractive: true, + operatorSame: true, + amount: "50gen", + } as any); + + expect(validatorJoin).not.toHaveBeenCalled(); + expect(action["failSpinner"]).toHaveBeenCalledWith( + "Wizard failed", + expect.stringMatching(/--account/), + ); + }); + + test("invalid --funding-source fails clearly", async () => { + await run({fundingSource: "bogus", operatorSame: true, amount: "50gen"}); + + expect(validatorJoin).not.toHaveBeenCalled(); + expect(action["failSpinner"]).toHaveBeenCalledWith( + "Wizard failed", + expect.stringMatching(/funding-source/), + ); + }); +}); + +describe("ValidatorWizardAction --non-interactive (browser owner)", () => { + let action: ValidatorWizardAction; + let sendTransaction: ReturnType; + let bridgeClose: ReturnType; + let getBrowserWalletSessionSpy: any; + let getStakingClientSpy: any; + + beforeEach(() => { + vi.clearAllMocks(); + action = new ValidatorWizardAction(); + + for (const m of [ + "startSpinner", + "setSpinnerText", + "succeedSpinner", + "failSpinner", + "stopSpinner", + "logInfo", + "logWarning", + "logError", + "log", + ]) { + vi.spyOn(action as any, m).mockImplementation(() => {}); + } + + vi.spyOn(action as any, "getCustomNetworks").mockReturnValue({}); + vi.spyOn(action as any, "getConfigByKey").mockReturnValue("testnet-bradbury"); + vi.spyOn(action as any, "writeConfig").mockImplementation(() => {}); + + sendTransaction = vi.fn().mockResolvedValue({ + transactionHash: "0xJoinHash", + blockNumber: 10n, + status: "success", + }); + bridgeClose = vi.fn().mockResolvedValue(undefined); + getBrowserWalletSessionSpy = vi.spyOn(action as any, "getBrowserWalletSession").mockResolvedValue({ + bridge: {close: bridgeClose}, + kind: "local", + sessionUrl: "http://127.0.0.1:1/#s=t", + stakingAddress: "0xStaking", + signerAddress: "0xBrowserOwner", + sendTransaction, + close: bridgeClose, + }); + + getStakingClientSpy = vi.spyOn(action as any, "getStakingClient"); + vi.spyOn(action as any, "listAccounts").mockReturnValue([]); + vi.mocked(CreateAccountAction.prototype.execute).mockResolvedValue(undefined as any); + vi.mocked(ExportAccountAction.prototype.execute).mockResolvedValue(undefined as any); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("browser owner runs end-to-end through the bridge with ZERO prompts", async () => { + await action.execute({ + wallet: "browser", + network: "testnet-bradbury", + nonInteractive: true, + operator: "0x2222222222222222222222222222222222222222", + amount: "50gen", + skipIdentity: true, + } as any); + + expect(inquirer.prompt).not.toHaveBeenCalled(); + // Join went through the bridge, never the keystore staking client. + expect(sendTransaction).toHaveBeenCalledWith( + expect.objectContaining({data: "0xjoin", label: expect.stringContaining("Join as validator")}), + ); + expect(getStakingClientSpy).not.toHaveBeenCalled(); + expect(bridgeClose).toHaveBeenCalled(); + expect(action["succeedSpinner"]).toHaveBeenCalledWith( + "Validator created successfully!", + expect.objectContaining({validatorWallet: "0xValidatorWalletFromEvent"}), + ); + }); +});