From e967b411f606d28cc1bec41edf4fa29a92288176 Mon Sep 17 00:00:00 2001 From: Edgars Date: Sun, 9 Aug 2026 16:03:25 +0100 Subject: [PATCH 1/3] fix: sign validator registration proofs --- src/commands/staking/StakingAction.ts | 64 ++++++++++++++++++++++--- src/commands/staking/index.ts | 4 +- src/commands/staking/validatorJoin.ts | 39 +++++++++++---- src/commands/staking/wizard.ts | 46 ++++++++++++++++-- src/commands/vesting/VestingAction.ts | 50 +++++++++++++++++-- src/commands/vesting/index.ts | 2 +- src/commands/vesting/validatorCreate.ts | 18 +++++-- src/commands/vesting/vestingTypes.ts | 11 ++++- tests/actions/staking.test.ts | 31 ++++++++++-- tests/actions/stakingWizard.test.ts | 37 ++++++++++---- tests/actions/vesting.test.ts | 5 ++ tests/commands/vesting.test.ts | 12 ++++- 12 files changed, 269 insertions(+), 50 deletions(-) diff --git a/src/commands/staking/StakingAction.ts b/src/commands/staking/StakingAction.ts index 62c762eb..5d3cc275 100644 --- a/src/commands/staking/StakingAction.ts +++ b/src/commands/staking/StakingAction.ts @@ -1,6 +1,6 @@ import {BaseAction, BUILT_IN_NETWORKS, resolveNetwork} from "../../lib/actions/BaseAction"; -import {createClient, createAccount, formatStakingAmount, parseStakingAmount, abi} from "genlayer-js"; -import type {GenLayerClient, GenLayerChain, Address} from "genlayer-js/types"; +import {createClient, createAccount, createOperatorRegistration, formatStakingAmount, parseStakingAmount, abi} from "genlayer-js"; +import type {GenLayerClient, GenLayerChain, Address, OperatorRegistrationProof} from "genlayer-js/types"; import {readFileSync, existsSync} from "fs"; import {ethers, ZeroAddress} from "ethers"; import {createPublicClient, http} from "viem"; @@ -35,6 +35,7 @@ export type BrowserWalletSession = BrowserSession & {stakingAddress: string}; export class StakingAction extends BaseAction { private _stakingClient: GenLayerClient | null = null; + private _stakingPrivateKey: string | undefined; private _passwordOverride: string | undefined; constructor() { @@ -154,6 +155,7 @@ export class StakingAction extends BaseAction { } const privateKey = await this.getPrivateKeyForStaking(); + this._stakingPrivateKey = privateKey; const account = createAccount(privateKey as `0x${string}`); this._stakingClient = createClient({ @@ -203,8 +205,14 @@ export class StakingAction extends BaseAction { }); } - private async getPrivateKeyForStaking(): Promise { - const accountName = this.resolveAccountName(); + protected async getPrivateKeyForStaking(): Promise { + return this.getPrivateKeyForAccount(this.resolveAccountName(), this._passwordOverride); + } + + protected async getPrivateKeyForAccount( + accountName: string, + passwordOverride?: string, + ): Promise { const keystorePath = this.getKeystorePath(accountName); if (!existsSync(keystorePath)) { @@ -237,8 +245,8 @@ export class StakingAction extends BaseAction { } let password: string; - if (this._passwordOverride) { - password = this._passwordOverride; + if (passwordOverride) { + password = passwordOverride; } else { this.stopSpinner(); password = await this.promptPassword(`Enter password to unlock account '${accountName}':`); @@ -249,6 +257,50 @@ export class StakingAction extends BaseAction { return wallet.privateKey; } + protected async createValidatorRegistration( + client: GenLayerClient, + operatorAccountName?: string, + ): Promise { + const context = await client.getValidatorRegistrationContext(); + const accountName = operatorAccountName || this.resolveAccountName(); + const isOwnerAccount = accountName === this.resolveAccountName(); + const privateKey = isOwnerAccount && this._stakingPrivateKey + ? this._stakingPrivateKey + : await this.getPrivateKeyForAccount( + accountName, + isOwnerAccount ? this._passwordOverride : undefined, + ); + return createOperatorRegistration({ + privateKey: privateKey as `0x${string}`, + ...context, + }); + } + + protected async createVestingValidatorRegistration( + client: GenLayerClient, + vesting: Address, + operatorAccountName: string, + ): Promise { + const isOwnerAccount = operatorAccountName === this.resolveAccountName(); + const [context, privateKey] = await Promise.all([ + client.getVestingValidatorRegistrationContext(vesting), + isOwnerAccount && this._stakingPrivateKey + ? Promise.resolve(this._stakingPrivateKey) + : this.getPrivateKeyForAccount( + operatorAccountName, + isOwnerAccount ? this._passwordOverride : undefined, + ), + ]); + return createOperatorRegistration({ + privateKey: privateKey as `0x${string}`, + ...context, + }); + } + + protected findLocalAccountByAddress(address: string): string | undefined { + return this.listAccounts().find(account => account.address.toLowerCase() === address.toLowerCase())?.name; + } + protected parseAmount(amount: string): bigint { return parseStakingAmount(amount); } diff --git a/src/commands/staking/index.ts b/src/commands/staking/index.ts index f1cc249b..feb783eb 100644 --- a/src/commands/staking/index.ts +++ b/src/commands/staking/index.ts @@ -34,7 +34,7 @@ export function initializeStakingCommands(program: Command) { .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("--operator
", "Operator address for an imported local CLI account (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)") @@ -63,7 +63,7 @@ export function initializeStakingCommands(program: Command) { "--amount ", "Amount to stake (in wei or with 'eth'/'gen' suffix, e.g., '42000gen')", ) - .option("--operator
", "Operator address (defaults to signer)") + .option("--operator
", "Operator address for an imported local CLI account (defaults to signer)") .option("--account ", "Account to use") .option("--password ", "Password to unlock account (skips interactive prompt)") .option("--network ", "built-in or custom network alias (see: genlayer network list)") diff --git a/src/commands/staking/validatorJoin.ts b/src/commands/staking/validatorJoin.ts index 8318dab4..2cd4f663 100644 --- a/src/commands/staking/validatorJoin.ts +++ b/src/commands/staking/validatorJoin.ts @@ -1,5 +1,5 @@ import {StakingAction, StakingConfig} from "./StakingAction"; -import type {Address, GenLayerClient, GenLayerChain} from "genlayer-js/types"; +import type {GenLayerClient, GenLayerChain} from "genlayer-js/types"; export interface ValidatorJoinOptions extends StakingConfig { amount: string; @@ -57,18 +57,27 @@ export class ValidatorJoinAction extends StakingAction { const client = await this.getStakingClient(options); const amount = this.parseAmount(options.amount); const signerAddress = await this.getSignerAddress(); + const operatorAccount = options.operator && options.operator.toLowerCase() !== signerAddress.toLowerCase() + ? this.findLocalAccountByAddress(options.operator) + : undefined; + if (options.operator && options.operator.toLowerCase() !== signerAddress.toLowerCase() && !operatorAccount) { + throw new Error( + `Operator ${options.operator} must match a local CLI account so its proof of possession can be signed. ` + + "Import the operator keystore first or omit --operator to use the owner account.", + ); + } await this.preflight(client, amount, options.force); + const registration = await this.createValidatorRegistration(client, operatorAccount); + this.setSpinnerText(`Creating validator with ${this.formatAmount(amount)} stake...`); this.log(` From: ${signerAddress}`); - if (options.operator) { - this.log(` Operator: ${options.operator}`); - } + this.log(` Operator: ${registration.operator}`); const result = await client.validatorJoin({ amount, - operator: options.operator as Address | undefined, + registration, }); const output = { @@ -99,20 +108,32 @@ export class ValidatorJoinAction extends StakingAction { try { const amount = this.parseAmount(options.amount); const client = this.getBrowserStakingClient(options, session); + if (!options.operator) { + throw new Error( + "Browser-wallet validator joins require --operator to name a local CLI operator account. " + + "Import or create the operator keystore first.", + ); + } + const operatorAccount = this.findLocalAccountByAddress(options.operator); + if (!operatorAccount) { + throw new Error( + `Operator ${options.operator} must match a local CLI account so its proof of possession can be signed.`, + ); + } await this.preflight(client, amount, options.force); + const registration = await this.createValidatorRegistration(client, operatorAccount); + this.log(` From (browser wallet): ${session.signerAddress}`); - if (options.operator) { - this.log(` Operator: ${options.operator}`); - } + this.log(` Operator: ${registration.operator}`); // Same SDK call as the keystore lane; the SDK decodes the ValidatorJoin // event and returns validatorWallet for both lanes. session.setNextLabel(`Join as validator (${this.formatAmount(amount)})`); const result = await client.validatorJoin({ amount, - operator: options.operator as Address | undefined, + registration, }); this.succeedSpinner("Validator created successfully!", { diff --git a/src/commands/staking/wizard.ts b/src/commands/staking/wizard.ts index 290f59df..19e8c230 100644 --- a/src/commands/staking/wizard.ts +++ b/src/commands/staking/wizard.ts @@ -1146,12 +1146,19 @@ export class ValidatorWizardAction extends StakingAction { }); const amount = this.parseAmount(state.stakeAmount!); + const operatorAccountName = state.operatorAccountName || this.findLocalAccountByAddress(state.operatorAddress!); + if (!operatorAccountName) { + throw new Error( + `Operator ${state.operatorAddress} must match a local CLI account so its proof of possession can be signed.`, + ); + } + const registration = await this.createValidatorRegistration(client, operatorAccountName); this.setSpinnerText(`Creating validator with ${this.formatAmount(amount)} stake...`); const result = await client.validatorJoin({ amount, - operator: state.operatorAddress as Address, + registration, }); // Save the validator wallet address @@ -1179,12 +1186,19 @@ export class ValidatorWizardAction extends StakingAction { this.startSpinner("Confirm the transaction in your browser wallet..."); try { + const operatorAccountName = state.operatorAccountName || this.findLocalAccountByAddress(state.operatorAddress!); + if (!operatorAccountName) { + throw new Error( + `Operator ${state.operatorAddress} must match a local CLI account so its proof of possession can be signed.`, + ); + } + const registration = await this.createValidatorRegistration(client, operatorAccountName); // Same SDK call as the keystore lane; the SDK decodes the ValidatorJoin // event and returns validatorWallet for both lanes. session.setNextLabel(`Join as validator (${this.formatAmount(amount)})`); const result = await client.validatorJoin({ amount, - operator: state.operatorAddress as Address, + registration, }); state.validatorWalletAddress = ensureHexPrefix(result.validatorWallet); @@ -1226,6 +1240,17 @@ export class ValidatorWizardAction extends StakingAction { const amount = this.parseAmount(state.stakeAmount!); const vesting = state.vestingContract as Address; + const operatorAccountName = state.operatorAccountName || this.findLocalAccountByAddress(state.operatorAddress!); + if (!operatorAccountName) { + throw new Error( + `Operator ${state.operatorAddress} must match a local CLI account so its proof of possession can be signed.`, + ); + } + const registration = await this.createVestingValidatorRegistration( + client, + vesting, + operatorAccountName, + ); this.setSpinnerText(`Creating validator with ${this.formatAmount(amount)} from vesting ${vesting}...`); @@ -1233,7 +1258,7 @@ export class ValidatorWizardAction extends StakingAction { // optional validatorWallet/wallet fields, so read them off the local shim. const result: VestingValidatorJoinResult = await client.vestingValidatorJoin({ vesting, - operator: state.operatorAddress as Address, + registration, amount, }); @@ -1283,10 +1308,21 @@ export class ValidatorWizardAction extends StakingAction { this.startSpinner("Confirm the transaction in your browser wallet..."); try { + const operatorAccountName = state.operatorAccountName || this.findLocalAccountByAddress(state.operatorAddress!); + if (!operatorAccountName) { + throw new Error( + `Operator ${state.operatorAddress} must match a local CLI account so its proof of possession can be signed.`, + ); + } + const registration = await this.createVestingValidatorRegistration( + client, + vesting, + operatorAccountName, + ); session.setNextLabel(`Create vesting validator (${this.formatAmount(amount)})`); const result = await client.vestingValidatorJoin({ vesting, - operator: state.operatorAddress as Address, + registration, amount, }); @@ -1306,7 +1342,7 @@ export class ValidatorWizardAction extends StakingAction { vesting, validatorWallet: state.validatorWalletAddress, amount: this.formatAmount(amount), - operator: state.operatorAddress, + operator: registration.operator, blockNumber: result.blockNumber.toString(), }); diff --git a/src/commands/vesting/VestingAction.ts b/src/commands/vesting/VestingAction.ts index 08800330..4e359161 100644 --- a/src/commands/vesting/VestingAction.ts +++ b/src/commands/vesting/VestingAction.ts @@ -1,6 +1,6 @@ import {BaseAction, BUILT_IN_NETWORKS, resolveNetwork} from "../../lib/actions/BaseAction"; -import {createClient, createAccount, formatStakingAmount, parseStakingAmount} from "genlayer-js"; -import type {Address, GenLayerChain} from "genlayer-js/types"; +import {createClient, createAccount, createOperatorRegistration, formatStakingAmount, parseStakingAmount} from "genlayer-js"; +import type {Address, GenLayerChain, OperatorRegistrationProof} from "genlayer-js/types"; import {existsSync, readFileSync} from "fs"; import {ethers} from "ethers"; import type {VestingClient, VestingFactoryLookupOptions} from "./vestingTypes"; @@ -24,6 +24,7 @@ export interface VestingConfig { export class VestingAction extends BaseAction { private _vestingClient: VestingClient | null = null; + private _vestingPrivateKey: string | undefined; private _passwordOverride: string | undefined; constructor() { @@ -49,6 +50,7 @@ export class VestingAction extends BaseAction { const network = this.getNetwork(config); const privateKey = await this.getPrivateKeyForVesting(); + this._vestingPrivateKey = privateKey; const account = createAccount(privateKey as `0x${string}`); this._vestingClient = createClient({ @@ -74,7 +76,13 @@ export class VestingAction extends BaseAction { } private async getPrivateKeyForVesting(): Promise { - const accountName = this.resolveAccountName(); + return this.getPrivateKeyForAccount(this.resolveAccountName(), this._passwordOverride); + } + + protected async getPrivateKeyForAccount( + accountName: string, + passwordOverride?: string, + ): Promise { const keystorePath = this.getKeystorePath(accountName); if (!existsSync(keystorePath)) { @@ -102,8 +110,8 @@ export class VestingAction extends BaseAction { } let password: string; - if (this._passwordOverride) { - password = this._passwordOverride; + if (passwordOverride) { + password = passwordOverride; } else { this.stopSpinner(); password = await this.promptPassword(`Enter password to unlock account '${accountName}':`); @@ -114,6 +122,38 @@ export class VestingAction extends BaseAction { return wallet.privateKey; } + protected findLocalAccountByAddress(address: string): string | undefined { + return this.listAccounts().find(account => account.address.toLowerCase() === address.toLowerCase())?.name; + } + + protected async createVestingValidatorRegistration( + client: VestingClient, + vesting: Address, + operator: Address, + ): Promise { + const operatorAccount = this.findLocalAccountByAddress(operator); + if (!operatorAccount) { + throw new Error( + `Operator ${operator} must match a local CLI account so its proof of possession can be signed. ` + + "Import the operator keystore first.", + ); + } + const isOwnerAccount = operatorAccount === this.resolveAccountName(); + const [context, privateKey] = await Promise.all([ + client.getVestingValidatorRegistrationContext(vesting), + isOwnerAccount && this._vestingPrivateKey + ? Promise.resolve(this._vestingPrivateKey) + : this.getPrivateKeyForAccount( + operatorAccount, + isOwnerAccount ? this._passwordOverride : undefined, + ), + ]); + return createOperatorRegistration({ + privateKey: privateKey as `0x${string}`, + ...context, + }); + } + protected parseAmount(amount: string): bigint { return parseStakingAmount(amount); } diff --git a/src/commands/vesting/index.ts b/src/commands/vesting/index.ts index 73b67a31..e7157215 100644 --- a/src/commands/vesting/index.ts +++ b/src/commands/vesting/index.ts @@ -149,7 +149,7 @@ export function initializeVestingCommands(program: Command) { validator .command(`${name} [operator]`) .description("Create a vesting-backed validator") - .option("--operator
", "Operator address (deprecated, use positional arg)") + .option("--operator
", "Operator address for an imported local CLI account (deprecated, use positional arg)") .requiredOption("--amount ", "Amount to self-stake (in wei or with 'eth'/'gen' suffix)") .option("--force", "Proceed even if self-stake is below the minimum required to become active"), ).action(async (operatorArg: string | undefined, options: VestingValidatorCreateOptions) => { diff --git a/src/commands/vesting/validatorCreate.ts b/src/commands/vesting/validatorCreate.ts index a614914e..b592f503 100644 --- a/src/commands/vesting/validatorCreate.ts +++ b/src/commands/vesting/validatorCreate.ts @@ -55,11 +55,17 @@ export class VestingValidatorCreateAction extends VestingAction { await this.preflight(client, amount, options.force); + const registration = await this.createVestingValidatorRegistration( + client, + vesting, + options.operator as Address, + ); + this.setSpinnerText(`Creating validator with ${this.formatAmount(amount)} from vesting ${vesting}...`); const result = await client.vestingValidatorJoin({ vesting, - operator: options.operator as Address, + registration, amount, }); @@ -109,10 +115,16 @@ export class VestingValidatorCreateAction extends VestingAction { await this.preflight(client, amount, options.force); + const registration = await this.createVestingValidatorRegistration( + client, + vesting, + options.operator as Address, + ); + session.setNextLabel("Create vesting validator"); const result = await client.vestingValidatorJoin({ vesting, - operator: options.operator as Address, + registration, amount, }); @@ -131,7 +143,7 @@ export class VestingValidatorCreateAction extends VestingAction { transactionHash: result.transactionHash, vesting, validatorWallet, - operator: options.operator, + operator: registration.operator, amount: this.formatAmount(amount), blockNumber: result.blockNumber.toString(), gasUsed: result.gasUsed.toString(), diff --git a/src/commands/vesting/vestingTypes.ts b/src/commands/vesting/vestingTypes.ts index 667b1915..5060f3f5 100644 --- a/src/commands/vesting/vestingTypes.ts +++ b/src/commands/vesting/vestingTypes.ts @@ -1,4 +1,10 @@ -import type {Address, GenLayerChain, GenLayerClient} from "genlayer-js/types"; +import type { + Address, + GenLayerChain, + GenLayerClient, + OperatorRegistrationContext, + OperatorRegistrationProof, +} from "genlayer-js/types"; // LOCKSTEP(genlayer-js#feat/vesting-actions): local CLI-facing type shim until // genlayer-js#v2-dev publishes VestingActions and VestingState. @@ -106,9 +112,10 @@ export type VestingClient = GenLayerClient & { }) => Promise; vestingValidatorJoin: (options: { vesting: Address; - operator: Address; + registration: OperatorRegistrationProof; amount: bigint | string; }) => Promise; + getVestingValidatorRegistrationContext: (vesting: Address) => Promise; vestingValidatorDeposit: (options: { vesting: Address; wallet: Address; diff --git a/tests/actions/staking.test.ts b/tests/actions/staking.test.ts index 177b1686..63b09e35 100644 --- a/tests/actions/staking.test.ts +++ b/tests/actions/staking.test.ts @@ -14,6 +14,7 @@ import {StakingInfoAction} from "../../src/commands/staking/stakingInfo"; vi.mock("genlayer-js", () => ({ createClient: vi.fn(), createAccount: vi.fn(() => ({address: "0xMockedAddress"})), + createOperatorRegistration: vi.fn(), formatStakingAmount: vi.fn((val: bigint) => `${Number(val) / 1e18} GEN`), parseStakingAmount: vi.fn((val: string) => { if (val.toLowerCase().endsWith("gen") || val.toLowerCase().endsWith("eth")) { @@ -60,6 +61,12 @@ const mockValidatorJoinResult = { amountRaw: 42000n * BigInt(1e18), }; +const mockRegistration = { + operator: "0xOperator", + operatorPubKey: [1n, 2n] as const, + possessionProof: "0x1234" as `0x${string}`, +}; + const mockDelegatorJoinResult = { ...mockTxResult, validator: "0xValidator", @@ -91,6 +98,7 @@ function setupActionMocks(action: any) { vi.spyOn(action as any, "getStakingClient").mockResolvedValue(mockClient); vi.spyOn(action as any, "getReadOnlyStakingClient").mockResolvedValue(mockClient); vi.spyOn(action as any, "getSignerAddress").mockResolvedValue("0xMockedSigner"); + vi.spyOn(action as any, "createValidatorRegistration").mockResolvedValue(mockRegistration); vi.spyOn(action as any, "startSpinner").mockImplementation(() => {}); vi.spyOn(action as any, "setSpinnerText").mockImplementation(() => {}); vi.spyOn(action as any, "succeedSpinner").mockImplementation(() => {}); @@ -119,7 +127,7 @@ describe("ValidatorJoinAction", () => { expect(mockClient.validatorJoin).toHaveBeenCalledWith({ amount: expect.any(BigInt), - operator: undefined, + registration: mockRegistration, }); expect(action["succeedSpinner"]).toHaveBeenCalledWith( "Validator created successfully!", @@ -128,11 +136,12 @@ describe("ValidatorJoinAction", () => { }); test("joins as validator with operator", async () => { + vi.spyOn(action as any, "findLocalAccountByAddress").mockReturnValue("operator"); await action.execute({amount: "42000gen", operator: "0xOperator", stakingAddress: "0xStaking"}); expect(mockClient.validatorJoin).toHaveBeenCalledWith({ amount: expect.any(BigInt), - operator: "0xOperator", + registration: mockRegistration, }); }); @@ -594,6 +603,8 @@ describe("ValidatorJoinAction --wallet browser", () => { vi.spyOn(action as any, "succeedSpinner").mockImplementation(() => {}); vi.spyOn(action as any, "failSpinner").mockImplementation(() => {}); vi.spyOn(action as any, "log").mockImplementation(() => {}); + vi.spyOn(action as any, "findLocalAccountByAddress").mockReturnValue("operator"); + vi.spyOn(action as any, "createValidatorRegistration").mockResolvedValue(mockRegistration); }); afterEach(() => { @@ -615,7 +626,12 @@ describe("ValidatorJoinAction --wallet browser", () => { }; vi.spyOn(action as any, "getBrowserStakingClient").mockReturnValue(mockBrowserClient); - await action.execute({amount: "42000gen", wallet: "browser", stakingAddress: "0xStaking"}); + await action.execute({ + amount: "42000gen", + operator: "0xOperator", + wallet: "browser", + stakingAddress: "0xStaking", + }); expect((action as any).getBrowserWalletSession).toHaveBeenCalledWith( expect.any(Object), @@ -623,7 +639,7 @@ describe("ValidatorJoinAction --wallet browser", () => { ); expect(mockBrowserClient.validatorJoin).toHaveBeenCalledWith({ amount: expect.any(BigInt), - operator: undefined, + registration: mockRegistration, }); expect(session.setNextLabel).toHaveBeenCalledWith(expect.stringContaining("Join as validator")); expect(getStakingClientSpy).not.toHaveBeenCalled(); @@ -651,7 +667,12 @@ describe("ValidatorJoinAction --wallet browser", () => { getEpochInfo: vi.fn().mockResolvedValue(mockEpochInfo), }); - await action.execute({amount: "42000gen", wallet: "browser", stakingAddress: "0xStaking"}); + await action.execute({ + amount: "42000gen", + operator: "0xOperator", + wallet: "browser", + stakingAddress: "0xStaking", + }); expect(action["failSpinner"]).toHaveBeenCalledWith( "Failed to create validator", diff --git a/tests/actions/stakingWizard.test.ts b/tests/actions/stakingWizard.test.ts index e21cd52f..b6f4788e 100644 --- a/tests/actions/stakingWizard.test.ts +++ b/tests/actions/stakingWizard.test.ts @@ -42,6 +42,7 @@ const mockGlClient = { vi.mock("genlayer-js", () => ({ createClient: vi.fn(() => mockGlClient), createAccount: vi.fn(() => ({address: "0xMockedAddress"})), + createOperatorRegistration: vi.fn(), formatStakingAmount: vi.fn((val: bigint) => `${Number(val) / 1e18} GEN`), parseStakingAmount: vi.fn((val: string) => { const cleaned = val.toLowerCase().replace(/gen|eth/g, ""); @@ -50,6 +51,18 @@ vi.mock("genlayer-js", () => ({ abi: {STAKING_ABI: [], VESTING_ABI: []}, })); +const mockRegistration = { + operator: "0xOperatorAddr", + operatorPubKey: [1n, 2n] as const, + possessionProof: "0x1234" as `0x${string}`, +}; + +function mockRegistrationHelpers(action: ValidatorWizardAction) { + vi.spyOn(action as any, "findLocalAccountByAddress").mockReturnValue("operator"); + vi.spyOn(action as any, "createValidatorRegistration").mockResolvedValue(mockRegistration); + vi.spyOn(action as any, "createVestingValidatorRegistration").mockResolvedValue(mockRegistration); +} + describe("ValidatorWizardAction --wallet browser (owner)", () => { let action: ValidatorWizardAction; let sendTransaction: ReturnType; @@ -69,6 +82,7 @@ describe("ValidatorWizardAction --wallet browser (owner)", () => { beforeEach(() => { vi.clearAllMocks(); action = new ValidatorWizardAction(); + mockRegistrationHelpers(action); // Silence spinners/logs. for (const m of [ @@ -193,7 +207,7 @@ describe("ValidatorWizardAction --wallet browser (owner)", () => { // Join ran the SAME SDK call as the keystore lane, through the browser // client (Address account + provider), not the keystore staking client. // Operator is the created operator account (fsMock address), passed straight through. - expect(validatorJoin).toHaveBeenCalledWith({amount: 42n * 10n ** 18n, operator: "0xOperatorAddr"}); + expect(validatorJoin).toHaveBeenCalledWith({amount: 42n * 10n ** 18n, registration: mockRegistration}); expect(setNextLabel).toHaveBeenCalledWith(expect.stringContaining("Join as validator")); expect(getStakingClientSpy).not.toHaveBeenCalled(); @@ -234,7 +248,7 @@ describe("ValidatorWizardAction --wallet browser (owner)", () => { // operator + amount — the same SDK call as `vesting validator create`. expect(vestingValidatorJoin).toHaveBeenCalledWith({ vesting: "0xVesting", - operator: "0xOperatorAddr", + registration: mockRegistration, amount: 42n * 10n ** 18n, }); expect(setNextLabel).toHaveBeenCalledWith(expect.stringContaining("Create vesting validator")); @@ -309,6 +323,7 @@ describe("ValidatorWizardAction stake source (keystore owner)", () => { beforeEach(() => { vi.clearAllMocks(); action = new ValidatorWizardAction(); + mockRegistrationHelpers(action); for (const m of [ "startSpinner", @@ -395,7 +410,7 @@ describe("ValidatorWizardAction stake source (keystore owner)", () => { await run(); - expect(validatorJoin).toHaveBeenCalledWith({amount: 42n * 10n ** 18n, operator: "0xOwner"}); + expect(validatorJoin).toHaveBeenCalledWith({amount: 42n * 10n ** 18n, registration: mockRegistration}); expect(vestingValidatorJoin).not.toHaveBeenCalled(); expect(mockGlClient.getBeneficiaryVestings).not.toHaveBeenCalled(); expect(action["succeedSpinner"]).toHaveBeenCalledWith( @@ -420,7 +435,7 @@ describe("ValidatorWizardAction stake source (keystore owner)", () => { expect(mockGlClient.getBeneficiaryVestings).toHaveBeenCalledWith("0xOwner"); expect(vestingValidatorJoin).toHaveBeenCalledWith({ vesting: "0xVesting", - operator: "0xOperatorAddr", + registration: mockRegistration, amount: 42n * 10n ** 18n, }); expect(validatorJoin).not.toHaveBeenCalled(); @@ -466,7 +481,7 @@ describe("ValidatorWizardAction stake source (keystore owner)", () => { expect(vestingValidatorJoin).toHaveBeenCalledWith({ vesting: "0xV2", - operator: "0xOwner", + registration: mockRegistration, amount: 42n * 10n ** 18n, }); expect(validatorJoin).not.toHaveBeenCalled(); @@ -588,6 +603,7 @@ describe("ValidatorWizardAction --non-interactive (keystore owner)", () => { beforeEach(() => { vi.clearAllMocks(); action = new ValidatorWizardAction(); + mockRegistrationHelpers(action); for (const m of [ "startSpinner", @@ -671,7 +687,7 @@ describe("ValidatorWizardAction --non-interactive (keystore owner)", () => { expect(inquirer.prompt).not.toHaveBeenCalled(); // Joined from the wallet with the external operator. - expect(validatorJoin).toHaveBeenCalledWith({amount: 50n * 10n ** 18n, operator: EXTERNAL_OP}); + expect(validatorJoin).toHaveBeenCalledWith({amount: 50n * 10n ** 18n, registration: mockRegistration}); expect(vestingValidatorJoin).not.toHaveBeenCalled(); expect(getBrowserWalletSessionSpy).not.toHaveBeenCalled(); @@ -701,7 +717,7 @@ describe("ValidatorWizardAction --non-interactive (keystore owner)", () => { expect(inquirer.prompt).not.toHaveBeenCalled(); // --operator-same reuses the owner address. - expect(validatorJoin).toHaveBeenCalledWith({amount: 50n * 10n ** 18n, operator: "0xOwner"}); + expect(validatorJoin).toHaveBeenCalledWith({amount: 50n * 10n ** 18n, registration: mockRegistration}); }); test("no --moniker: identity step is skipped (no setIdentity), still zero prompts", async () => { @@ -725,7 +741,7 @@ describe("ValidatorWizardAction --non-interactive (keystore owner)", () => { expect(mockGlClient.getBeneficiaryVestings).not.toHaveBeenCalled(); expect(vestingValidatorJoin).toHaveBeenCalledWith({ vesting: "0xVesting", - operator: EXTERNAL_OP, + registration: mockRegistration, amount: 50n * 10n ** 18n, }); expect(validatorJoin).not.toHaveBeenCalled(); @@ -739,7 +755,7 @@ describe("ValidatorWizardAction --non-interactive (keystore owner)", () => { expect(mockGlClient.getBeneficiaryVestings).toHaveBeenCalledWith("0xOwner"); expect(vestingValidatorJoin).toHaveBeenCalledWith({ vesting: "0xOnlyVesting", - operator: "0xOwner", + registration: mockRegistration, amount: 50n * 10n ** 18n, }); }); @@ -836,6 +852,7 @@ describe("ValidatorWizardAction --non-interactive (browser owner)", () => { beforeEach(() => { vi.clearAllMocks(); action = new ValidatorWizardAction(); + mockRegistrationHelpers(action); for (const m of [ "startSpinner", @@ -904,7 +921,7 @@ describe("ValidatorWizardAction --non-interactive (browser owner)", () => { // the keystore staking client. expect(validatorJoin).toHaveBeenCalledWith({ amount: 50n * 10n ** 18n, - operator: "0x2222222222222222222222222222222222222222", + registration: mockRegistration, }); expect(setNextLabel).toHaveBeenCalledWith(expect.stringContaining("Join as validator")); expect(getStakingClientSpy).not.toHaveBeenCalled(); diff --git a/tests/actions/vesting.test.ts b/tests/actions/vesting.test.ts index e3a9680b..cbf70285 100644 --- a/tests/actions/vesting.test.ts +++ b/tests/actions/vesting.test.ts @@ -207,6 +207,11 @@ function setupVestingKeystoreMocks(action: any, clientOverrides: Record {}); vi.spyOn(action, "logWarning").mockImplementation(() => {}); vi.spyOn(action, "resolveBeneficiaryVesting").mockResolvedValue("0xVesting"); + vi.spyOn(action, "createVestingValidatorRegistration").mockResolvedValue({ + operator: "0xOperator", + operatorPubKey: [1n, 2n], + possessionProof: "0x1234", + }); const client = { vestingValidatorJoin: vi.fn().mockResolvedValue({ transactionHash: "0xVH", diff --git a/tests/commands/vesting.test.ts b/tests/commands/vesting.test.ts index 7a606529..70240a94 100644 --- a/tests/commands/vesting.test.ts +++ b/tests/commands/vesting.test.ts @@ -8,6 +8,7 @@ import {VestingValidatorDepositAction} from "../../src/commands/vesting/validato vi.mock("genlayer-js", () => ({ createClient: vi.fn(), createAccount: vi.fn(() => ({address: "0xBeneficiary"})), + createOperatorRegistration: vi.fn(), formatStakingAmount: vi.fn((value: bigint) => `${Number(value) / 1e18} GEN`), parseStakingAmount: vi.fn((value: string) => { const lower = value.toLowerCase(); @@ -31,6 +32,12 @@ const mockTxResult = { gasUsed: 21000n, }; +const mockRegistration = { + operator: "0xOperator", + operatorPubKey: [1n, 2n] as const, + possessionProof: "0x1234" as `0x${string}`, +}; + const mockVestingState = { name: "Team grant", category: 1, @@ -178,6 +185,7 @@ describe("vesting commands", () => { vi.spyOn(VestingAction.prototype as any, "getReadOnlyVestingClient").mockResolvedValue(mockClient); vi.spyOn(VestingAction.prototype as any, "getVestingClient").mockResolvedValue(mockClient); + vi.spyOn(VestingAction.prototype as any, "createVestingValidatorRegistration").mockResolvedValue(mockRegistration); vi.spyOn(VestingAction.prototype as any, "getSignerAddress").mockResolvedValue("0xBeneficiary"); vi.spyOn(VestingAction.prototype as any, "startSpinner").mockImplementation(() => {}); vi.spyOn(VestingAction.prototype as any, "setSpinnerText").mockImplementation(() => {}); @@ -333,7 +341,7 @@ describe("vesting commands", () => { expect(mockClient.vestingValidatorJoin).toHaveBeenCalledWith({ vesting: "0xVesting", - operator: "0xOperator", + registration: mockRegistration, amount: expect.any(BigInt), }); }); @@ -356,7 +364,7 @@ describe("vesting commands", () => { expect(mockClient.getBeneficiaryVestings).not.toHaveBeenCalled(); expect(mockClient.vestingValidatorJoin).toHaveBeenCalledWith({ vesting: "0xExplicitVesting", - operator: "0xOperator", + registration: mockRegistration, amount: expect.any(BigInt), }); }); From a05fa944243bada1b070fe8017aaf58ec9a704c5 Mon Sep 17 00:00:00 2001 From: Edgars Date: Sun, 9 Aug 2026 16:15:46 +0100 Subject: [PATCH 2/3] chore: pin proof-bearing SDK revision --- package-lock.json | 5 +++-- package.json | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index f6db0176..57da0433 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,7 +46,7 @@ "eslint-config-prettier": "^10.0.0", "eslint-import-resolver-typescript": "^4.0.0", "eslint-plugin-import": "^2.29.1", - "genlayer-js": "github:genlayerlabs/genlayer-js#v2-dev", + "genlayer-js": "github:genlayerlabs/genlayer-js#6f1273885567ff5cda77b7459edfd6666c5859d0", "jsdom": "^26.0.0", "prettier": "^3.2.5", "release-it": "^19.0.0", @@ -5683,7 +5683,8 @@ }, "node_modules/genlayer-js": { "version": "1.1.8", - "resolved": "git+ssh://git@github.com/genlayerlabs/genlayer-js.git#bf42f13a66a2bb762e5ef1065eb89789b9c45d4a", + "resolved": "git+ssh://git@github.com/genlayerlabs/genlayer-js.git#6f1273885567ff5cda77b7459edfd6666c5859d0", + "integrity": "sha512-ts4KjgqO/qR8pKiCOp+g5FKbAKUyLLIFTuXX6+ZjFYKOhTHPP3kL+CR4siVKp4YGVWWk+/DWeVXgCMZOBo1QLA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index badba938..02f0ee9f 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "eslint-config-prettier": "^10.0.0", "eslint-import-resolver-typescript": "^4.0.0", "eslint-plugin-import": "^2.29.1", - "genlayer-js": "github:genlayerlabs/genlayer-js#v2-dev", + "genlayer-js": "github:genlayerlabs/genlayer-js#6f1273885567ff5cda77b7459edfd6666c5859d0", "jsdom": "^26.0.0", "prettier": "^3.2.5", "release-it": "^19.0.0", From cb5d5f4fb31aa29543641605095a8b89373d7865 Mon Sep 17 00:00:00 2001 From: Edgars Date: Sun, 9 Aug 2026 16:33:21 +0100 Subject: [PATCH 3/3] fix: cover proof-bearing browser joins --- e2e/config-default.e2e.ts | 46 ++++++- e2e/errors.e2e.ts | 23 +++- e2e/fixtures/StakingStub.json | 134 ++++++++++-------- e2e/fixtures/StakingStub.sol | 32 ++++- e2e/fixtures/cli.ts | 22 +++ e2e/lane-a-staking.e2e.ts | 65 ++++++++- src/commands/staking/StakingAction.ts | 28 ++-- src/commands/staking/index.ts | 187 ++++++++++++++------------ src/commands/staking/validatorJoin.ts | 26 +++- tests/actions/staking.test.ts | 37 ++++- tests/commands/staking.test.ts | 21 +++ 11 files changed, 443 insertions(+), 178 deletions(-) diff --git a/e2e/config-default.e2e.ts b/e2e/config-default.e2e.ts index 5a2674c0..e113c80c 100644 --- a/e2e/config-default.e2e.ts +++ b/e2e/config-default.e2e.ts @@ -1,6 +1,13 @@ import {test, expect, type Browser} from "@playwright/test"; -import {startAnvil, readStubCallCount, type AnvilHandle} from "./fixtures/chain"; -import {makeScratchEnv, runCli, readDescriptor, isPidAlive, type ScratchEnv} from "./fixtures/cli"; +import {ANVIL_KEY_0, startAnvil, readStubCallCount, type AnvilHandle} from "./fixtures/chain"; +import { + installLocalOperator, + makeScratchEnv, + runCli, + readDescriptor, + isPidAlive, + type ScratchEnv, +} from "./fixtures/cli"; import {launchBrowser, establishSession, type BridgeDriver} from "./helpers/bridgePage"; /** @@ -11,12 +18,14 @@ import {launchBrowser, establishSession, type BridgeDriver} from "./helpers/brid */ const CHAIN_ID = 61343; const HASH_RE = /0x[0-9a-fA-F]{64}/; +const OPERATOR_PASSWORD = "operator-password"; test.describe.serial("S5 config default walletMode=browser", () => { let anvil: AnvilHandle; let browser: Browser; let driver: BridgeDriver; let scratch: ScratchEnv; + let operator: `0x${string}`; test.beforeAll(async () => { anvil = await startAnvil({chainId: CHAIN_ID}); @@ -28,6 +37,10 @@ test.describe.serial("S5 config default walletMode=browser", () => { walletMode: "browser", timing: {longPollMs: 1000}, }); + operator = await installLocalOperator(scratch, { + privateKey: ANVIL_KEY_0, + password: OPERATOR_PASSWORD, + }); ({driver} = await establishSession(browser, anvil, scratch)); }); @@ -46,7 +59,20 @@ test.describe.serial("S5 config default walletMode=browser", () => { test("bare validator-join signs via session (no --wallet flag)", async () => { const before = await readStubCallCount(anvil); - const res = await runCli(["staking", "validator-join", "--force", "--amount", "1"], scratch); + const res = await runCli( + [ + "staking", + "validator-join", + "--force", + "--amount", + "1", + "--operator", + operator, + "--operator-password", + OPERATOR_PASSWORD, + ], + scratch, + ); expect(res.all).toContain("Validator created successfully!"); expect(res.all.match(HASH_RE)?.[0]).toBeTruthy(); expect(await readStubCallCount(anvil)).toBe(before + 1); @@ -55,7 +81,19 @@ test.describe.serial("S5 config default walletMode=browser", () => { test("--wallet keystore overrides config, takes keystore path (no enqueue)", async () => { const before = await readStubCallCount(anvil); const res = await runCli( - ["staking", "validator-join", "--force", "--amount", "1", "--wallet", "keystore"], + [ + "staking", + "validator-join", + "--force", + "--amount", + "1", + "--wallet", + "keystore", + "--operator", + operator, + "--operator-password", + OPERATOR_PASSWORD, + ], scratch, ); // Keystore path selected: it fails on the missing account rather than diff --git a/e2e/errors.e2e.ts b/e2e/errors.e2e.ts index 4cf6cab2..98c288f4 100644 --- a/e2e/errors.e2e.ts +++ b/e2e/errors.e2e.ts @@ -1,6 +1,7 @@ import {test, expect, type Browser} from "@playwright/test"; -import {startAnvil, type AnvilHandle} from "./fixtures/chain"; +import {ANVIL_KEY_0, startAnvil, type AnvilHandle} from "./fixtures/chain"; import { + installLocalOperator, makeScratchEnv, runCli, readDescriptor, @@ -22,10 +23,12 @@ import {launchBrowser, establishSession, type BridgeDriver} from "./helpers/brid test.describe.serial("S6a user reject (4001)", () => { const CHAIN_ID = 61344; + const OPERATOR_PASSWORD = "operator-password"; let anvil: AnvilHandle; let browser: Browser; let driver: BridgeDriver; let scratch: ScratchEnv; + let operator: `0x${string}`; test.beforeAll(async () => { anvil = await startAnvil({chainId: CHAIN_ID}); @@ -36,6 +39,10 @@ test.describe.serial("S6a user reject (4001)", () => { stubAddress: anvil.stubAddress, timing: {longPollMs: 1000}, }); + operator = await installLocalOperator(scratch, { + privateKey: ANVIL_KEY_0, + password: OPERATOR_PASSWORD, + }); ({driver} = await establishSession(browser, anvil, scratch, {behavior: "reject"})); }); @@ -54,7 +61,19 @@ test.describe.serial("S6a user reject (4001)", () => { test("reject surfaces the message, exits non-zero, session survives", async () => { const res = await runCli( - ["staking", "validator-join", "--force", "--amount", "1", "--wallet", "browser"], + [ + "staking", + "validator-join", + "--force", + "--amount", + "1", + "--wallet", + "browser", + "--operator", + operator, + "--operator-password", + OPERATOR_PASSWORD, + ], scratch, ); expect(res.all).toContain("Transaction rejected in wallet"); diff --git a/e2e/fixtures/StakingStub.json b/e2e/fixtures/StakingStub.json index 1cd8d180..046b40d1 100644 --- a/e2e/fixtures/StakingStub.json +++ b/e2e/fixtures/StakingStub.json @@ -1,114 +1,138 @@ { "abi": [ { - "type": "function", - "name": "callCount", - "inputs": [], - "outputs": [ + "anonymous": false, + "inputs": [ { - "name": "", - "type": "uint256", - "internalType": "uint256" + "indexed": false, + "internalType": "address", + "name": "operator", + "type": "address" + }, + { + "indexed": false, + "internalType": "address", + "name": "validator", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" } ], - "stateMutability": "view" + "name": "ValidatorJoin", + "type": "event" }, { - "type": "function", - "name": "lastAmount", "inputs": [], + "name": "callCount", "outputs": [ { + "internalType": "uint256", "name": "", - "type": "uint256", - "internalType": "uint256" + "type": "uint256" } ], - "stateMutability": "view" + "stateMutability": "view", + "type": "function" }, { - "type": "function", - "name": "lastOperator", - "inputs": [], + "inputs": [ + { + "internalType": "string", + "name": "_name", + "type": "string" + } + ], + "name": "getAddress", "outputs": [ { + "internalType": "address", "name": "", - "type": "address", - "internalType": "address" + "type": "address" } ], - "stateMutability": "view" + "stateMutability": "view", + "type": "function" }, { - "type": "function", - "name": "lastValidator", "inputs": [], + "name": "getAddressManager", "outputs": [ { + "internalType": "address", "name": "", - "type": "address", - "internalType": "address" + "type": "address" } ], - "stateMutability": "view" + "stateMutability": "view", + "type": "function" }, { - "type": "function", - "name": "validatorJoin", - "inputs": [ + "inputs": [], + "name": "lastAmount", + "outputs": [ { - "name": "_operator", - "type": "address", - "internalType": "address" + "internalType": "uint256", + "name": "", + "type": "uint256" } ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "lastOperator", "outputs": [ { + "internalType": "address", "name": "", - "type": "address", - "internalType": "address" + "type": "address" } ], - "stateMutability": "payable" + "stateMutability": "view", + "type": "function" }, { - "type": "function", - "name": "validatorJoin", "inputs": [], + "name": "lastValidator", "outputs": [ { + "internalType": "address", "name": "", - "type": "address", - "internalType": "address" + "type": "address" } ], - "stateMutability": "payable" + "stateMutability": "view", + "type": "function" }, { - "type": "event", - "name": "ValidatorJoin", "inputs": [ { - "name": "operator", - "type": "address", - "indexed": false, - "internalType": "address" + "internalType": "uint256[2]", + "name": "_operatorPubKey", + "type": "uint256[2]" }, { - "name": "validator", - "type": "address", - "indexed": false, - "internalType": "address" - }, + "internalType": "bytes", + "name": "", + "type": "bytes" + } + ], + "name": "validatorJoin", + "outputs": [ { - "name": "amount", - "type": "uint256", - "indexed": false, - "internalType": "uint256" + "internalType": "address", + "name": "", + "type": "address" } ], - "anonymous": false + "stateMutability": "payable", + "type": "function" } ], - "bytecode": "0x6080604052348015600e575f5ffd5b5061050e8061001c5f395ff3fe608060405260043610610054575f3560e01c806301fe0a781461005857806330e7349f1461008857806330ebe468146100b25780634b28f9a2146100d05780636eb3cd49146100fa578063829a86d914610124575b5f5ffd5b610072600480360381019061006d919061032f565b61014e565b60405161007f9190610369565b60405180910390f35b348015610093575f5ffd5b5061009c61015f565b6040516100a99190610369565b60405180910390f35b6100ba610184565b6040516100c79190610369565b60405180910390f35b3480156100db575f5ffd5b506100e4610193565b6040516100f1919061039a565b60405180910390f35b348015610105575f5ffd5b5061010e610198565b60405161011b9190610369565b60405180910390f35b34801561012f575f5ffd5b506101386101bd565b604051610145919061039a565b60405180910390f35b5f610158826101c3565b9050919050565b60025f9054906101000a900473ffffffffffffffffffffffffffffffffffffffff1681565b5f61018e336101c3565b905090565b5f5481565b60015f9054906101000a900473ffffffffffffffffffffffffffffffffffffffff1681565b60035481565b5f60015f5f8282546101d591906103e0565b92505081905550335f546040516020016101f0929190610478565b604051602081830303815290604052805190602001205f1c90508160015f6101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055508060025f6101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff160217905550346003819055507f2b7297b31452d0a13e57c605870ec3c9b4ac6ef33e5cbae7a0f00bc2a3e967828282346040516102c4939291906104a3565b60405180910390a1919050565b5f5ffd5b5f73ffffffffffffffffffffffffffffffffffffffff82169050919050565b5f6102fe826102d5565b9050919050565b61030e816102f4565b8114610318575f5ffd5b50565b5f8135905061032981610305565b92915050565b5f60208284031215610344576103436102d1565b5b5f6103518482850161031b565b91505092915050565b610363816102f4565b82525050565b5f60208201905061037c5f83018461035a565b92915050565b5f819050919050565b61039481610382565b82525050565b5f6020820190506103ad5f83018461038b565b92915050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b5f6103ea82610382565b91506103f583610382565b925082820190508082111561040d5761040c6103b3565b5b92915050565b5f8160601b9050919050565b5f61042982610413565b9050919050565b5f61043a8261041f565b9050919050565b61045261044d826102f4565b610430565b82525050565b5f819050919050565b61047261046d82610382565b610458565b82525050565b5f6104838285610441565b6014820191506104938284610461565b6020820191508190509392505050565b5f6060820190506104b65f83018661035a565b6104c3602083018561035a565b6104d0604083018461038b565b94935050505056fea26469706673582212202ea3a8dac16f254ee61dc37f81eb395845a459b3af57d33fab7a95c141e032b664736f6c63430008210033" + "bytecode": "0x6080604052348015600e575f5ffd5b506107f48061001c5f395ff3fe60806040526004361061006f575f3560e01c80636eb3cd491161004d5780636eb3cd49146100f1578063829a86d91461011b578063bf40fac114610145578063ea7845f5146101815761006f565b806330e7349f146100735780634b28f9a21461009d5780635492f302146100c7575b5f5ffd5b34801561007e575f5ffd5b506100876101b1565b604051610094919061041c565b60405180910390f35b3480156100a8575f5ffd5b506100b16101d6565b6040516100be919061044d565b60405180910390f35b3480156100d2575f5ffd5b506100db6101db565b6040516100e8919061041c565b60405180910390f35b3480156100fc575f5ffd5b506101056101e2565b604051610112919061041c565b60405180910390f35b348015610126575f5ffd5b5061012f610207565b60405161013c919061044d565b60405180910390f35b348015610150575f5ffd5b5061016b600480360381019061016691906104cf565b61020d565b604051610178919061041c565b60405180910390f35b61019b60048036038101906101969190610590565b61025e565b6040516101a8919061041c565b60405180910390f35b60025f9054906101000a900473ffffffffffffffffffffffffffffffffffffffff1681565b5f5481565b5f30905090565b60015f9054906101000a900473ffffffffffffffffffffffffffffffffffffffff1681565b60035481565b5f7fd6eae5385dae362d454164c313be55f54f548cd511c3559d852e3f3ffe7d65b6838360405161023f929190610629565b60405180910390200361025457309050610258565b5f90505b92915050565b5f5f845f6002811061027357610272610641565b5b60200201358560016002811061028c5761028b610641565b5b60200201356040516020016102a292919061068e565b604051602081830303815290604052805190602001205f1c90506102c5816102cf565b9150509392505050565b5f60015f5f8282546102e191906106e6565b92505081905550335f546040516020016102fc92919061075e565b604051602081830303815290604052805190602001205f1c90508160015f6101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055508060025f6101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff160217905550346003819055507f2b7297b31452d0a13e57c605870ec3c9b4ac6ef33e5cbae7a0f00bc2a3e967828282346040516103d093929190610789565b60405180910390a1919050565b5f73ffffffffffffffffffffffffffffffffffffffff82169050919050565b5f610406826103dd565b9050919050565b610416816103fc565b82525050565b5f60208201905061042f5f83018461040d565b92915050565b5f819050919050565b61044781610435565b82525050565b5f6020820190506104605f83018461043e565b92915050565b5f5ffd5b5f5ffd5b5f5ffd5b5f5ffd5b5f5ffd5b5f5f83601f84011261048f5761048e61046e565b5b8235905067ffffffffffffffff8111156104ac576104ab610472565b5b6020830191508360018202830111156104c8576104c7610476565b5b9250929050565b5f5f602083850312156104e5576104e4610466565b5b5f83013567ffffffffffffffff8111156105025761050161046a565b5b61050e8582860161047a565b92509250509250929050565b5f8190508260206002028201111561053557610534610476565b5b92915050565b5f5f83601f8401126105505761054f61046e565b5b8235905067ffffffffffffffff81111561056d5761056c610472565b5b60208301915083600182028301111561058957610588610476565b5b9250929050565b5f5f5f606084860312156105a7576105a6610466565b5b5f6105b48682870161051a565b935050604084013567ffffffffffffffff8111156105d5576105d461046a565b5b6105e18682870161053b565b92509250509250925092565b5f81905092915050565b828183375f83830152505050565b5f61061083856105ed565b935061061d8385846105f7565b82840190509392505050565b5f610635828486610605565b91508190509392505050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52603260045260245ffd5b5f819050919050565b61068861068382610435565b61066e565b82525050565b5f6106998285610677565b6020820191506106a98284610677565b6020820191508190509392505050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b5f6106f082610435565b91506106fb83610435565b9250828201905080821115610713576107126106b9565b5b92915050565b5f8160601b9050919050565b5f61072f82610719565b9050919050565b5f61074082610725565b9050919050565b610758610753826103fc565b610736565b82525050565b5f6107698285610747565b6014820191506107798284610677565b6020820191508190509392505050565b5f60608201905061079c5f83018661040d565b6107a9602083018561040d565b6107b6604083018461043e565b94935050505056fea26469706673582212202e7c5c3be52ba719aa8579433c475f973a17163db3866f0a1b1df84a267d892c64736f6c63430008220033" } diff --git a/e2e/fixtures/StakingStub.sol b/e2e/fixtures/StakingStub.sol index 758043ee..8f0bf728 100644 --- a/e2e/fixtures/StakingStub.sol +++ b/e2e/fixtures/StakingStub.sol @@ -2,8 +2,11 @@ pragma solidity ^0.8.13; /// Minimal recording stub for the Tier-2 browser-wallet e2e harness. -/// Mimics the Staking `validatorJoin` selectors and emits the ValidatorJoin +/// Mimics the proof-bearing Staking `validatorJoin` selector and emits the ValidatorJoin /// event (address operator, address validator, uint256 amount) the CLI decodes. +/// It also stands in for ConsensusMain, AddressManager, and +/// ValidatorWalletFactory so the SDK can resolve and verify the registration +/// domain before the browser signs the transaction. /// It stands up NO consensus; it only records the call so the sign->broadcast /// ->receipt loop can be asserted end to end. contract StakingStub { @@ -14,12 +17,31 @@ contract StakingStub { address public lastValidator; uint256 public lastAmount; - function validatorJoin() external payable returns (address) { - return _join(msg.sender); + function getAddressManager() external view returns (address) { + return address(this); } - function validatorJoin(address _operator) external payable returns (address) { - return _join(_operator); + function getAddress(string calldata _name) external view returns (address) { + if (keccak256(bytes(_name)) == keccak256("ValidatorWalletFactory")) { + return address(this); + } + return address(0); + } + + function validatorJoin( + uint256[2] calldata _operatorPubKey, + bytes calldata + ) external payable returns (address) { + address operator = address( + uint160( + uint256( + keccak256( + abi.encodePacked(_operatorPubKey[0], _operatorPubKey[1]) + ) + ) + ) + ); + return _join(operator); } function _join(address operator) internal returns (address validator) { diff --git a/e2e/fixtures/cli.ts b/e2e/fixtures/cli.ts index 11f3a917..ab2490a4 100644 --- a/e2e/fixtures/cli.ts +++ b/e2e/fixtures/cli.ts @@ -14,6 +14,7 @@ import {tmpdir} from "node:os"; import {join, resolve} from "node:path"; import {fileURLToPath} from "node:url"; import {dirname} from "node:path"; +import {Wallet} from "ethers"; const __dirname = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(__dirname, "..", ".."); @@ -58,6 +59,7 @@ export function makeScratchEnv(opts: { rpcUrl: opts.rpcUrl, chainId: opts.chainId, staking: opts.stubAddress, + consensusMain: opts.stubAddress, }, }, }, @@ -82,6 +84,26 @@ export function makeScratchEnv(opts: { return {home, env, descriptorPath: join(genlayerDir, "wallet-session.json")}; } +/** Install a deterministic encrypted operator keystore in a scratch CLI home. */ +export async function installLocalOperator( + scratch: ScratchEnv, + options: { + name?: string; + privateKey: `0x${string}`; + password: string; + }, +): Promise<`0x${string}`> { + const name = options.name || "operator"; + const wallet = new Wallet(options.privateKey); + const encrypted = await wallet.encrypt(options.password); + const keystoresDir = join(scratch.home, ".genlayer", "keystores"); + mkdirSync(keystoresDir, {recursive: true, mode: 0o700}); + writeFileSync(join(keystoresDir, `${name}.json`), encrypted, { + mode: 0o600, + }); + return wallet.address as `0x${string}`; +} + export interface CliResult { exitCode: number; stdout: string; diff --git a/e2e/lane-a-staking.e2e.ts b/e2e/lane-a-staking.e2e.ts index 7b913e28..3e9bcb6d 100644 --- a/e2e/lane-a-staking.e2e.ts +++ b/e2e/lane-a-staking.e2e.ts @@ -1,6 +1,19 @@ import {test, expect, type Browser} from "@playwright/test"; -import {startAnvil, readStubCallCount, receiptSucceeded, type AnvilHandle} from "./fixtures/chain"; -import {makeScratchEnv, runCli, readDescriptor, isPidAlive, type ScratchEnv} from "./fixtures/cli"; +import { + ANVIL_KEY_0, + startAnvil, + readStubCallCount, + receiptSucceeded, + type AnvilHandle, +} from "./fixtures/chain"; +import { + installLocalOperator, + makeScratchEnv, + runCli, + readDescriptor, + isPidAlive, + type ScratchEnv, +} from "./fixtures/cli"; import {launchBrowser, establishSession, type BridgeDriver} from "./helpers/bridgePage"; /** @@ -13,12 +26,14 @@ import {launchBrowser, establishSession, type BridgeDriver} from "./helpers/brid */ const CHAIN_ID = 61342; const HASH_RE = /0x[0-9a-fA-F]{64}/; +const OPERATOR_PASSWORD = "operator-password"; test.describe.serial("Lane A staking (validator-join)", () => { let anvil: AnvilHandle; let browser: Browser; let driver: BridgeDriver; let scratch: ScratchEnv; + let operator: `0x${string}`; test.beforeAll(async () => { anvil = await startAnvil({chainId: CHAIN_ID}); @@ -29,6 +44,10 @@ test.describe.serial("Lane A staking (validator-join)", () => { stubAddress: anvil.stubAddress, timing: {longPollMs: 1000}, }); + operator = await installLocalOperator(scratch, { + privateKey: ANVIL_KEY_0, + password: OPERATOR_PASSWORD, + }); ({driver} = await establishSession(browser, anvil, scratch)); }); @@ -48,7 +67,19 @@ test.describe.serial("Lane A staking (validator-join)", () => { test("S2: validator-join --wallet browser signs and mines", async () => { const before = await readStubCallCount(anvil); const res = await runCli( - ["staking", "validator-join", "--force", "--amount", "1", "--wallet", "browser"], + [ + "staking", + "validator-join", + "--force", + "--amount", + "1", + "--wallet", + "browser", + "--operator", + operator, + "--operator-password", + OPERATOR_PASSWORD, + ], scratch, ); @@ -65,11 +96,35 @@ test.describe.serial("Lane A staking (validator-join)", () => { const before = await readStubCallCount(anvil); const first = await runCli( - ["staking", "validator-join", "--force", "--amount", "1", "--wallet", "browser"], + [ + "staking", + "validator-join", + "--force", + "--amount", + "1", + "--wallet", + "browser", + "--operator", + operator, + "--operator-password", + OPERATOR_PASSWORD, + ], scratch, ); const second = await runCli( - ["staking", "validator-join", "--force", "--amount", "2", "--wallet", "browser"], + [ + "staking", + "validator-join", + "--force", + "--amount", + "2", + "--wallet", + "browser", + "--operator", + operator, + "--operator-password", + OPERATOR_PASSWORD, + ], scratch, ); diff --git a/src/commands/staking/StakingAction.ts b/src/commands/staking/StakingAction.ts index 5d3cc275..b29d4d6a 100644 --- a/src/commands/staking/StakingAction.ts +++ b/src/commands/staking/StakingAction.ts @@ -1,5 +1,12 @@ import {BaseAction, BUILT_IN_NETWORKS, resolveNetwork} from "../../lib/actions/BaseAction"; -import {createClient, createAccount, createOperatorRegistration, formatStakingAmount, parseStakingAmount, abi} from "genlayer-js"; +import { + createClient, + createAccount, + createOperatorRegistration, + formatStakingAmount, + parseStakingAmount, + abi, +} from "genlayer-js"; import type {GenLayerClient, GenLayerChain, Address, OperatorRegistrationProof} from "genlayer-js/types"; import {readFileSync, existsSync} from "fs"; import {ethers, ZeroAddress} from "ethers"; @@ -209,10 +216,7 @@ export class StakingAction extends BaseAction { return this.getPrivateKeyForAccount(this.resolveAccountName(), this._passwordOverride); } - protected async getPrivateKeyForAccount( - accountName: string, - passwordOverride?: string, - ): Promise { + protected async getPrivateKeyForAccount(accountName: string, passwordOverride?: string): Promise { const keystorePath = this.getKeystorePath(accountName); if (!existsSync(keystorePath)) { @@ -260,16 +264,18 @@ export class StakingAction extends BaseAction { protected async createValidatorRegistration( client: GenLayerClient, operatorAccountName?: string, + operatorPassword?: string, ): Promise { const context = await client.getValidatorRegistrationContext(); const accountName = operatorAccountName || this.resolveAccountName(); const isOwnerAccount = accountName === this.resolveAccountName(); - const privateKey = isOwnerAccount && this._stakingPrivateKey - ? this._stakingPrivateKey - : await this.getPrivateKeyForAccount( - accountName, - isOwnerAccount ? this._passwordOverride : undefined, - ); + const privateKey = + isOwnerAccount && this._stakingPrivateKey + ? this._stakingPrivateKey + : await this.getPrivateKeyForAccount( + accountName, + isOwnerAccount ? this._passwordOverride : operatorPassword, + ); return createOperatorRegistration({ privateKey: privateKey as `0x${string}`, ...context, diff --git a/src/commands/staking/index.ts b/src/commands/staking/index.ts index feb783eb..f9afbc18 100644 --- a/src/commands/staking/index.ts +++ b/src/commands/staking/index.ts @@ -23,7 +23,9 @@ 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). Every prompt can be supplied by a flag; pass --non-interactive to run scripted with zero prompts") + .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") @@ -32,12 +34,18 @@ export function initializeStakingCommands(program: Command) { // 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( + "--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
", "Operator address for an imported local CLI account (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-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 @@ -63,7 +71,14 @@ export function initializeStakingCommands(program: Command) { "--amount ", "Amount to stake (in wei or with 'eth'/'gen' suffix, e.g., '42000gen')", ) - .option("--operator
", "Operator address for an imported local CLI account (defaults to signer)") + .option( + "--operator
", + "Operator address for an imported local CLI account (defaults to signer)", + ) + .option( + "--operator-password ", + "Password for the selected operator keystore (for non-interactive proof signing)", + ) .option("--account ", "Account to use") .option("--password ", "Password to unlock account (skips interactive prompt)") .option("--network ", "built-in or custom network alias (see: genlayer network list)") @@ -87,14 +102,14 @@ export function initializeStakingCommands(program: Command) { .option("--rpc ", "RPC URL for the network") .option("--force", "Proceed even if self-stake is below the minimum required to become active"), ).action(async (validatorArg: string | undefined, options: ValidatorDepositOptions) => { - const validator = validatorArg || options.validator; - if (!validator) { - console.error("Error: validator address is required"); - process.exit(1); - } - const action = new ValidatorDepositAction(); - await action.execute({...options, validator}); - }); + const validator = validatorArg || options.validator; + if (!validator) { + console.error("Error: validator address is required"); + process.exit(1); + } + const action = new ValidatorDepositAction(); + await action.execute({...options, validator}); + }); addWalletModeOption( staking @@ -107,14 +122,14 @@ export function initializeStakingCommands(program: Command) { .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--rpc ", "RPC URL for the network"), ).action(async (validatorArg: string | undefined, options: ValidatorExitOptions) => { - const validator = validatorArg || options.validator; - if (!validator) { - console.error("Error: validator address is required"); - process.exit(1); - } - const action = new ValidatorExitAction(); - await action.execute({...options, validator}); - }); + const validator = validatorArg || options.validator; + if (!validator) { + console.error("Error: validator address is required"); + process.exit(1); + } + const action = new ValidatorExitAction(); + await action.execute({...options, validator}); + }); addWalletModeOption( staking @@ -126,14 +141,14 @@ export function initializeStakingCommands(program: Command) { .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--rpc ", "RPC URL for the network"), ).action(async (validatorArg: string | undefined, options: ValidatorClaimOptions) => { - const validator = validatorArg || options.validator; - if (!validator) { - console.error("Error: validator address is required"); - process.exit(1); - } - const action = new ValidatorClaimAction(); - await action.execute({...options, validator}); - }); + const validator = validatorArg || options.validator; + if (!validator) { + console.error("Error: validator address is required"); + process.exit(1); + } + const action = new ValidatorClaimAction(); + await action.execute({...options, validator}); + }); addWalletModeOption( staking @@ -146,14 +161,14 @@ export function initializeStakingCommands(program: Command) { .option("--rpc ", "RPC URL for the network") .option("--staking-address
", "Staking contract address (overrides chain config)"), ).action(async (validatorArg: string | undefined, options: ValidatorPrimeOptions) => { - const validator = validatorArg || options.validator; - if (!validator) { - console.error("Error: validator address is required"); - process.exit(1); - } - const action = new ValidatorPrimeAction(); - await action.execute({...options, validator}); - }); + const validator = validatorArg || options.validator; + if (!validator) { + console.error("Error: validator address is required"); + process.exit(1); + } + const action = new ValidatorPrimeAction(); + await action.execute({...options, validator}); + }); addWalletModeOption( staking @@ -165,9 +180,9 @@ export function initializeStakingCommands(program: Command) { .option("--rpc ", "RPC URL for the network") .option("--staking-address
", "Staking contract address (overrides chain config)"), ).action(async (options: StakingConfig) => { - const action = new ValidatorPrimeAction(); - await action.primeAll(options); - }); + const action = new ValidatorPrimeAction(); + await action.primeAll(options); + }); addWalletModeOption( staking @@ -180,21 +195,21 @@ export function initializeStakingCommands(program: Command) { .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--rpc ", "RPC URL for the network"), ).action( - async ( - validatorArg: string | undefined, - operatorArg: string | undefined, - options: SetOperatorOptions, - ) => { - const validator = validatorArg || options.validator; - const operator = operatorArg || options.operator; - if (!validator || !operator) { - console.error("Error: validator and operator addresses are required"); - process.exit(1); - } - const action = new SetOperatorAction(); - await action.execute({...options, validator, operator}); - }, - ); + async ( + validatorArg: string | undefined, + operatorArg: string | undefined, + options: SetOperatorOptions, + ) => { + const validator = validatorArg || options.validator; + const operator = operatorArg || options.operator; + if (!validator || !operator) { + console.error("Error: validator and operator addresses are required"); + process.exit(1); + } + const action = new SetOperatorAction(); + await action.execute({...options, validator, operator}); + }, + ); addWalletModeOption( staking @@ -215,14 +230,14 @@ export function initializeStakingCommands(program: Command) { .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--rpc ", "RPC URL for the network"), ).action(async (validatorArg: string | undefined, options: SetIdentityOptions) => { - const validator = validatorArg || options.validator; - if (!validator) { - console.error("Error: validator address is required"); - process.exit(1); - } - const action = new SetIdentityAction(); - await action.execute({...options, validator}); - }); + const validator = validatorArg || options.validator; + if (!validator) { + console.error("Error: validator address is required"); + process.exit(1); + } + const action = new SetIdentityAction(); + await action.execute({...options, validator}); + }); // Delegator commands addWalletModeOption( @@ -237,14 +252,14 @@ export function initializeStakingCommands(program: Command) { .option("--rpc ", "RPC URL for the network") .option("--staking-address
", "Staking contract address (overrides chain config)"), ).action(async (validatorArg: string | undefined, options: DelegatorJoinOptions) => { - const validator = validatorArg || options.validator; - if (!validator) { - console.error("Error: validator address is required"); - process.exit(1); - } - const action = new DelegatorJoinAction(); - await action.execute({...options, validator}); - }); + const validator = validatorArg || options.validator; + if (!validator) { + console.error("Error: validator address is required"); + process.exit(1); + } + const action = new DelegatorJoinAction(); + await action.execute({...options, validator}); + }); addWalletModeOption( staking @@ -258,14 +273,14 @@ export function initializeStakingCommands(program: Command) { .option("--rpc ", "RPC URL for the network") .option("--staking-address
", "Staking contract address (overrides chain config)"), ).action(async (validatorArg: string | undefined, options: DelegatorExitOptions) => { - const validator = validatorArg || options.validator; - if (!validator) { - console.error("Error: validator address is required"); - process.exit(1); - } - const action = new DelegatorExitAction(); - await action.execute({...options, validator}); - }); + const validator = validatorArg || options.validator; + if (!validator) { + console.error("Error: validator address is required"); + process.exit(1); + } + const action = new DelegatorExitAction(); + await action.execute({...options, validator}); + }); addWalletModeOption( staking @@ -279,14 +294,14 @@ export function initializeStakingCommands(program: Command) { .option("--rpc ", "RPC URL for the network") .option("--staking-address
", "Staking contract address (overrides chain config)"), ).action(async (validatorArg: string | undefined, options: DelegatorClaimOptions) => { - const validator = validatorArg || options.validator; - if (!validator) { - console.error("Error: validator address is required"); - process.exit(1); - } - const action = new DelegatorClaimAction(); - await action.execute({...options, validator}); - }); + const validator = validatorArg || options.validator; + if (!validator) { + console.error("Error: validator address is required"); + process.exit(1); + } + const action = new DelegatorClaimAction(); + await action.execute({...options, validator}); + }); // Info commands staking diff --git a/src/commands/staking/validatorJoin.ts b/src/commands/staking/validatorJoin.ts index 2cd4f663..80219f13 100644 --- a/src/commands/staking/validatorJoin.ts +++ b/src/commands/staking/validatorJoin.ts @@ -4,6 +4,7 @@ import type {GenLayerClient, GenLayerChain} from "genlayer-js/types"; export interface ValidatorJoinOptions extends StakingConfig { amount: string; operator?: string; + operatorPassword?: string; force?: boolean; } @@ -57,10 +58,15 @@ export class ValidatorJoinAction extends StakingAction { const client = await this.getStakingClient(options); const amount = this.parseAmount(options.amount); const signerAddress = await this.getSignerAddress(); - const operatorAccount = options.operator && options.operator.toLowerCase() !== signerAddress.toLowerCase() - ? this.findLocalAccountByAddress(options.operator) - : undefined; - if (options.operator && options.operator.toLowerCase() !== signerAddress.toLowerCase() && !operatorAccount) { + const operatorAccount = + options.operator && options.operator.toLowerCase() !== signerAddress.toLowerCase() + ? this.findLocalAccountByAddress(options.operator) + : undefined; + if ( + options.operator && + options.operator.toLowerCase() !== signerAddress.toLowerCase() && + !operatorAccount + ) { throw new Error( `Operator ${options.operator} must match a local CLI account so its proof of possession can be signed. ` + "Import the operator keystore first or omit --operator to use the owner account.", @@ -69,7 +75,11 @@ export class ValidatorJoinAction extends StakingAction { await this.preflight(client, amount, options.force); - const registration = await this.createValidatorRegistration(client, operatorAccount); + const registration = await this.createValidatorRegistration( + client, + operatorAccount, + options.operatorPassword, + ); this.setSpinnerText(`Creating validator with ${this.formatAmount(amount)} stake...`); this.log(` From: ${signerAddress}`); @@ -123,7 +133,11 @@ export class ValidatorJoinAction extends StakingAction { await this.preflight(client, amount, options.force); - const registration = await this.createValidatorRegistration(client, operatorAccount); + const registration = await this.createValidatorRegistration( + client, + operatorAccount, + options.operatorPassword, + ); this.log(` From (browser wallet): ${session.signerAddress}`); this.log(` Operator: ${registration.operator}`); diff --git a/tests/actions/staking.test.ts b/tests/actions/staking.test.ts index 63b09e35..e4c599c6 100644 --- a/tests/actions/staking.test.ts +++ b/tests/actions/staking.test.ts @@ -137,12 +137,22 @@ describe("ValidatorJoinAction", () => { test("joins as validator with operator", async () => { vi.spyOn(action as any, "findLocalAccountByAddress").mockReturnValue("operator"); - await action.execute({amount: "42000gen", operator: "0xOperator", stakingAddress: "0xStaking"}); + await action.execute({ + amount: "42000gen", + operator: "0xOperator", + operatorPassword: "operator-password", + stakingAddress: "0xStaking", + }); expect(mockClient.validatorJoin).toHaveBeenCalledWith({ amount: expect.any(BigInt), registration: mockRegistration, }); + expect((action as any).createValidatorRegistration).toHaveBeenCalledWith( + mockClient, + "operator", + "operator-password", + ); }); test("handles errors", async () => { @@ -370,7 +380,11 @@ describe("SetIdentityAction", () => { test("handles errors", async () => { mockClient.setIdentity.mockRejectedValue(new Error("set identity failed")); - await action.execute({validator: "0xValidatorWallet", moniker: "MyValidator", stakingAddress: "0xStaking"}); + await action.execute({ + validator: "0xValidatorWallet", + moniker: "MyValidator", + stakingAddress: "0xStaking", + }); expect(action["failSpinner"]).toHaveBeenCalledWith("Failed to set identity", "set identity failed"); }); @@ -629,6 +643,7 @@ describe("ValidatorJoinAction --wallet browser", () => { await action.execute({ amount: "42000gen", operator: "0xOperator", + operatorPassword: "operator-password", wallet: "browser", stakingAddress: "0xStaking", }); @@ -641,6 +656,11 @@ describe("ValidatorJoinAction --wallet browser", () => { amount: expect.any(BigInt), registration: mockRegistration, }); + expect((action as any).createValidatorRegistration).toHaveBeenCalledWith( + mockBrowserClient, + "operator", + "operator-password", + ); expect(session.setNextLabel).toHaveBeenCalledWith(expect.stringContaining("Join as validator")); expect(getStakingClientSpy).not.toHaveBeenCalled(); expect(getSignerAddressSpy).not.toHaveBeenCalled(); @@ -1053,7 +1073,12 @@ describe("ValidatorDepositAction minimum gate + mixing guard", () => { mockClient.getValidatorInfo.mockResolvedValue(fullValidatorInfo({owner: "0xVestingContract"})); // Even with --force the mixing guard blocks (the tx would revert on-chain). - await action.execute({validator: "0xValidatorWallet", amount: "10gen", stakingAddress: "0xStaking", force: true}); + await action.execute({ + validator: "0xValidatorWallet", + amount: "10gen", + stakingAddress: "0xStaking", + force: true, + }); expect(mockClient.validatorDeposit).not.toHaveBeenCalled(); const [msg, detail] = (action["failSpinner"] as any).mock.calls[0]; @@ -1096,7 +1121,11 @@ describe("StakingInfoAction clean view + eligibility display", () => { test("clean view keeps load-bearing values as plain substrings (e2e grep safety)", async () => { mockClient.getValidatorInfo.mockResolvedValue( - fullValidatorInfo({vStake: "60000 GEN", vStakeRaw: 60000n * BigInt(1e18), identity: {moniker: "AcmeNode"}}), + fullValidatorInfo({ + vStake: "60000 GEN", + vStakeRaw: 60000n * BigInt(1e18), + identity: {moniker: "AcmeNode"}, + }), ); await action.getValidatorInfo({validator: "0xValidatorWallet", stakingAddress: "0xStaking"}); diff --git a/tests/commands/staking.test.ts b/tests/commands/staking.test.ts index 4107a4b2..4bf92aac 100644 --- a/tests/commands/staking.test.ts +++ b/tests/commands/staking.test.ts @@ -95,6 +95,27 @@ describe("staking commands", () => { }); }); + test("accepts a non-interactive operator keystore password", async () => { + program.parse([ + "node", + "test", + "staking", + "validator-join", + "--amount", + "42000gen", + "--operator", + "0xOperator", + "--operator-password", + "operator-password", + ]); + + expect(ValidatorJoinAction.prototype.execute).toHaveBeenCalledWith({ + amount: "42000gen", + operator: "0xOperator", + operatorPassword: "operator-password", + }); + }); + test("accepts staking-address option", async () => { program.parse([ "node",