From e967b411f606d28cc1bec41edf4fa29a92288176 Mon Sep 17 00:00:00 2001 From: Edgars Date: Sun, 9 Aug 2026 16:03:25 +0100 Subject: [PATCH 1/4] 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/4] 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/4] 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", From ec975e1c17c83e71d6a46082bc4cd5511f73c75c Mon Sep 17 00:00:00 2001 From: kirilaa Date: Tue, 18 Aug 2026 04:56:30 +0100 Subject: [PATCH 4/4] feat(staking): rotate operators via the two-step transfer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CON-715 removed ValidatorWalletBlueprint.setOperator in favour of initiateOperatorTransfer + completeOperatorTransfer, so `staking set-operator` fails against a consensus deployment carrying that change: the selector is gone and the call reverts with no decodable reason. set-operator now prefers the two-step flow and falls back to the single call when the wallet does not expose it, so it keeps working against both older and newer deployments. The two halves are also available on their own as `staking initiate-operator-transfer` and `staking complete-operator-transfer`, for deployments whose operatorTransferDelay is non-zero and where one command therefore cannot finish the rotation. The incoming operator has to sign its own possession proof, so its key must be reachable: --operator-account names the keystore entry, otherwise the operator address is looked up locally via findLocalAccountByAddress. Without a match the command takes the legacy path unchanged, which is what keeps existing callers working. Note the proof binding differs from a join: the wallet verifies rotation proofs itself, so createOperatorTransferRegistration builds them against the SDK's getOperatorTransferContext (registrar = the wallet) rather than the factory-bound validator registration context. Requires genlayer-js with initiateOperatorTransfer/completeOperatorTransfer. Verified: npm run build against the linked SDK, and vitest — 782 passing, including three new cases covering initiate+complete, a pending transfer when the delay has not elapsed, and the legacy fallback. tests/libs/keychainManager fails locally only because keytar's native binding is absent under --ignore-scripts. --- src/commands/staking/StakingAction.ts | 41 +++++- src/commands/staking/index.ts | 59 +++++++- src/commands/staking/setOperator.ts | 198 ++++++++++++++++++++++++-- tests/actions/staking.test.ts | 64 +++++++++ 4 files changed, 348 insertions(+), 14 deletions(-) diff --git a/src/commands/staking/StakingAction.ts b/src/commands/staking/StakingAction.ts index b29d4d6a..02d3b0ec 100644 --- a/src/commands/staking/StakingAction.ts +++ b/src/commands/staking/StakingAction.ts @@ -7,7 +7,13 @@ import { parseStakingAmount, abi, } from "genlayer-js"; -import type {GenLayerClient, GenLayerChain, Address, OperatorRegistrationProof} from "genlayer-js/types"; +import type { + GenLayerClient, + GenLayerChain, + Address, + OperatorRegistrationProof, + OperatorRegistrationContext, +} from "genlayer-js/types"; import {readFileSync, existsSync} from "fs"; import {ethers, ZeroAddress} from "ethers"; import {createPublicClient, http} from "viem"; @@ -282,6 +288,39 @@ export class StakingAction extends BaseAction { }); } + /** + * Builds the possession proof for a two-step operator rotation. + * + * Unlike createValidatorRegistration, whose proof the ValidatorWalletFactory + * verifies, this one is verified by the wallet itself — so the SDK's + * getOperatorTransferContext binds it to the wallet address. Everything else + * (resolving the incoming operator's key from the keystore) is the same. + */ + protected async createOperatorTransferRegistration( + client: GenLayerClient, + validator: Address, + operatorAccountName: string, + operatorPassword?: string, + ): Promise { + const clientWithTransfer = client as GenLayerClient & { + getOperatorTransferContext(v: Address): Promise; + }; + const isOwnerAccount = operatorAccountName === this.resolveAccountName(); + const [context, privateKey] = await Promise.all([ + clientWithTransfer.getOperatorTransferContext(validator), + isOwnerAccount && this._stakingPrivateKey + ? Promise.resolve(this._stakingPrivateKey) + : this.getPrivateKeyForAccount( + operatorAccountName, + isOwnerAccount ? this._passwordOverride : operatorPassword, + ), + ]); + return createOperatorRegistration({ + privateKey: privateKey as `0x${string}`, + ...context, + }); + } + protected async createVestingValidatorRegistration( client: GenLayerClient, vesting: Address, diff --git a/src/commands/staking/index.ts b/src/commands/staking/index.ts index f9afbc18..69cc370b 100644 --- a/src/commands/staking/index.ts +++ b/src/commands/staking/index.ts @@ -6,7 +6,13 @@ import {ValidatorDepositAction, ValidatorDepositOptions} from "./validatorDeposi import {ValidatorExitAction, ValidatorExitOptions} from "./validatorExit"; import {ValidatorClaimAction, ValidatorClaimOptions} from "./validatorClaim"; import {ValidatorPrimeAction, ValidatorPrimeOptions} from "./validatorPrime"; -import {SetOperatorAction, SetOperatorOptions} from "./setOperator"; +import { + SetOperatorAction, + SetOperatorOptions, + InitiateOperatorTransferAction, + CompleteOperatorTransferAction, + OperatorTransferOptions, +} from "./setOperator"; import {SetIdentityAction, SetIdentityOptions} from "./setIdentity"; import {DelegatorJoinAction, DelegatorJoinOptions} from "./delegatorJoin"; import {DelegatorExitAction, DelegatorExitOptions} from "./delegatorExit"; @@ -190,6 +196,8 @@ export function initializeStakingCommands(program: Command) { .description("Change the operator address for a validator wallet") .option("--validator
", "Validator wallet address (deprecated, use positional arg)") .option("--operator
", "New operator address (deprecated, use positional arg)") + .option("--operator-account ", "Keystore account holding the incoming operator key") + .option("--operator-password ", "Password to unlock the incoming operator account") .option("--account ", "Account to use (must be validator owner)") .option("--password ", "Password to unlock account (skips interactive prompt)") .option("--network ", "built-in or custom network alias (see: genlayer network list)") @@ -211,6 +219,55 @@ export function initializeStakingCommands(program: Command) { }, ); + // The two halves of the rotation, for deployments whose operatorTransferDelay + // is non-zero and where set-operator therefore cannot finish in one go. + addWalletModeOption( + staking + .command("initiate-operator-transfer [validator] [operator]") + .description("Start a two-step operator rotation for a validator wallet") + .option("--validator
", "Validator wallet address") + .option("--operator
", "Incoming operator address (key must be in the local keystore)") + .option("--operator-account ", "Keystore account holding the incoming operator key") + .option("--operator-password ", "Password to unlock the incoming operator account") + .option("--account ", "Account to use (must be validator owner)") + .option("--password ", "Password to unlock account (skips interactive prompt)") + .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: OperatorTransferOptions, + ) => { + const validator = validatorArg || options.validator; + if (!validator) { + console.error("Error: validator address is required"); + process.exit(1); + } + const action = new InitiateOperatorTransferAction(); + await action.execute({...options, validator, operator: operatorArg || options.operator}); + }, + ); + + addWalletModeOption( + staking + .command("complete-operator-transfer [validator]") + .description("Finalise a pending operator rotation once its delay has elapsed") + .option("--validator
", "Validator wallet address") + .option("--account ", "Account to use (validator owner or pending operator)") + .option("--password ", "Password to unlock account (skips interactive prompt)") + .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: OperatorTransferOptions) => { + const validator = validatorArg || options.validator; + if (!validator) { + console.error("Error: validator address is required"); + process.exit(1); + } + const action = new CompleteOperatorTransferAction(); + await action.execute({...options, validator}); + }); + addWalletModeOption( staking .command("set-identity [validator]") diff --git a/src/commands/staking/setOperator.ts b/src/commands/staking/setOperator.ts index e4221514..79538fd9 100644 --- a/src/commands/staking/setOperator.ts +++ b/src/commands/staking/setOperator.ts @@ -3,6 +3,7 @@ import type { Address, GenLayerClient, GenLayerChain, + OperatorRegistrationProof, SetOperatorOptions as SdkSetOperatorOptions, StakingTransactionResult, } from "genlayer-js/types"; @@ -10,6 +11,39 @@ import type { export interface SetOperatorOptions extends StakingConfig { validator: string; operator: string; + operatorAccount?: string; + operatorPassword?: string; +} + +/** + * CON-715 replaced the wallet's single-call setOperator with a two-step + * rotation: the owner initiates with a possession proof signed by the incoming + * operator, then completes once the factory's operatorTransferDelay elapses. + * Both surfaces exist in the wild — older deployments only have setOperator, + * newer ones only have the pair — so this command prefers the two-step flow and + * falls back when the wallet does not expose it. + */ +type OperatorTransferClient = GenLayerClient & { + initiateOperatorTransfer(o: { + validator: Address; + registration: OperatorRegistrationProof; + }): Promise; + completeOperatorTransfer(o: {validator: Address}): Promise; + getPendingOperator(validator: Address): Promise<{operator: Address; initiatedAt: bigint}>; +}; + +/** + * A wallet without the new surface has no such selector, so the call reverts + * with no decodable reason. Treat that — and an explicitly unknown function — + * as "this deployment predates CON-715" and retry the legacy path. + */ +function looksLikeMissingSelector(error: any): boolean { + const message = String(error?.message ?? error ?? ""); + return ( + /unknown reason/i.test(message) || + /function .*not found/i.test(message) || + /execution reverted/i.test(message) + ); } export class SetOperatorAction extends StakingAction { @@ -40,18 +74,7 @@ export class SetOperatorAction extends StakingAction { this.setSpinnerText(`Setting operator to ${options.operator}...`); - const result = await client.setOperator({ - validator: validatorWallet, - operator: options.operator as Address, - }); - - const output = { - transactionHash: result.transactionHash, - validator: validatorWallet, - newOperator: options.operator, - blockNumber: result.blockNumber.toString(), - gasUsed: result.gasUsed.toString(), - }; + const output = await this.rotateOperator(client, validatorWallet, options); this.succeedSpinner("Operator updated!", output); } catch (error: any) { @@ -59,6 +82,88 @@ export class SetOperatorAction extends StakingAction { } } + /** + * Rotates via initiate + complete, falling back to the retired single call. + * + * The incoming operator must sign its own possession proof, so its key has to + * be reachable: --operator-account names it, otherwise we look it up in the + * local keystore by address. Completion is attempted immediately because the + * delay is commonly 0; when it is not, the transfer is left pending and the + * caller is told to finish it with complete-operator-transfer. + */ + private async rotateOperator( + client: GenLayerClient & { + setOperator(o: SdkSetOperatorOptions): Promise; + }, + validatorWallet: Address, + options: SetOperatorOptions, + ): Promise> { + const operatorAccount = + options.operatorAccount || this.findLocalAccountByAddress(options.operator); + + if (operatorAccount) { + try { + const registration = await this.createOperatorTransferRegistration( + client, + validatorWallet, + operatorAccount, + options.operatorPassword, + ); + const transferClient = client as unknown as OperatorTransferClient; + + this.setSpinnerText(`Initiating operator transfer to ${options.operator}...`); + const initiated = await transferClient.initiateOperatorTransfer({ + validator: validatorWallet, + registration, + }); + + this.setSpinnerText("Completing operator transfer..."); + try { + const completed = await transferClient.completeOperatorTransfer({validator: validatorWallet}); + return { + transactionHash: completed.transactionHash, + initiateTransactionHash: initiated.transactionHash, + validator: validatorWallet, + newOperator: options.operator, + blockNumber: completed.blockNumber.toString(), + gasUsed: completed.gasUsed.toString(), + }; + } catch (completeError: any) { + return { + transactionHash: initiated.transactionHash, + validator: validatorWallet, + pendingOperator: options.operator, + blockNumber: initiated.blockNumber.toString(), + gasUsed: initiated.gasUsed.toString(), + note: + "Transfer initiated but not yet effective: " + + `${completeError?.message ?? completeError}. ` + + `Run: genlayer staking complete-operator-transfer ${validatorWallet}`, + }; + } + } catch (error: any) { + if (!looksLikeMissingSelector(error)) { + throw error; + } + // Wallet predates CON-715 — fall through to the single-call surface. + } + } + + this.setSpinnerText(`Setting operator to ${options.operator}...`); + const result = await client.setOperator({ + validator: validatorWallet, + operator: options.operator as Address, + }); + + return { + transactionHash: result.transactionHash, + validator: validatorWallet, + newOperator: options.operator, + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + }; + } + private async executeWithBrowserWallet(options: SetOperatorOptions): Promise { let session; try { @@ -98,3 +203,72 @@ export class SetOperatorAction extends StakingAction { } } } + +export interface OperatorTransferOptions extends StakingConfig { + validator: string; + operator?: string; + operatorAccount?: string; + operatorPassword?: string; +} + +/** Starts a rotation without completing it, for delays that are not zero. */ +export class InitiateOperatorTransferAction extends StakingAction { + async execute(options: OperatorTransferOptions): Promise { + this.startSpinner("Initiating operator transfer..."); + try { + const validatorWallet = options.validator as Address; + const operatorAccount = + options.operatorAccount || + (options.operator ? this.findLocalAccountByAddress(options.operator) : undefined); + if (!operatorAccount) { + throw new Error( + "The incoming operator must sign its own possession proof. Pass --operator-account " + + ", or an --operator
whose key is in the local keystore.", + ); + } + + const client = await this.getStakingClient(options); + const registration = await this.createOperatorTransferRegistration( + client, + validatorWallet, + operatorAccount, + options.operatorPassword, + ); + const result = await (client as unknown as OperatorTransferClient).initiateOperatorTransfer({ + validator: validatorWallet, + registration, + }); + + this.succeedSpinner("Operator transfer initiated!", { + transactionHash: result.transactionHash, + validator: validatorWallet, + pendingOperator: registration.operator, + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + }); + } catch (error: any) { + this.failSpinner("Failed to initiate operator transfer", error.message || error); + } + } +} + +/** Finalises a pending rotation once the transfer delay has elapsed. */ +export class CompleteOperatorTransferAction extends StakingAction { + async execute(options: OperatorTransferOptions): Promise { + this.startSpinner("Completing operator transfer..."); + try { + const validatorWallet = options.validator as Address; + const client = (await this.getStakingClient(options)) as unknown as OperatorTransferClient; + const result = await client.completeOperatorTransfer({validator: validatorWallet}); + + this.succeedSpinner("Operator transfer completed!", { + transactionHash: result.transactionHash, + validator: validatorWallet, + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + }); + } catch (error: any) { + this.failSpinner("Failed to complete operator transfer", error.message || error); + } + } +} diff --git a/tests/actions/staking.test.ts b/tests/actions/staking.test.ts index e4c599c6..c2744324 100644 --- a/tests/actions/staking.test.ts +++ b/tests/actions/staking.test.ts @@ -293,6 +293,70 @@ describe("SetOperatorAction", () => { expect(action["succeedSpinner"]).toHaveBeenCalledWith("Operator updated!", expect.any(Object)); }); + // CON-715 rotation. The incoming operator signs its own possession proof, so + // the two-step path is only reachable when its key is resolvable locally; + // without that the command must keep working against older wallets. + describe("two-step rotation", () => { + beforeEach(() => { + vi.spyOn(action as any, "findLocalAccountByAddress").mockReturnValue("rotated-acct"); + vi.spyOn(action as any, "createOperatorTransferRegistration").mockResolvedValue(mockRegistration); + mockClient.initiateOperatorTransfer = vi.fn().mockResolvedValue(mockTxResult); + mockClient.completeOperatorTransfer = vi.fn().mockResolvedValue(mockTxResult); + }); + + test("initiates and completes when the operator key is known", async () => { + await action.execute({ + validator: "0xValidatorWallet", + operator: "0xNewOperator", + stakingAddress: "0xStaking", + }); + + expect(mockClient.initiateOperatorTransfer).toHaveBeenCalledWith({ + validator: "0xValidatorWallet", + registration: mockRegistration, + }); + expect(mockClient.completeOperatorTransfer).toHaveBeenCalledWith({ + validator: "0xValidatorWallet", + }); + expect(mockClient.setOperator).not.toHaveBeenCalled(); + expect(action["succeedSpinner"]).toHaveBeenCalledWith("Operator updated!", expect.any(Object)); + }); + + test("reports a pending transfer when the delay has not elapsed", async () => { + mockClient.completeOperatorTransfer.mockRejectedValue(new Error("OperatorTransferNotReady")); + + await action.execute({ + validator: "0xValidatorWallet", + operator: "0xNewOperator", + stakingAddress: "0xStaking", + }); + + expect(mockClient.initiateOperatorTransfer).toHaveBeenCalled(); + expect(action["succeedSpinner"]).toHaveBeenCalledWith( + "Operator updated!", + expect.objectContaining({pendingOperator: "0xNewOperator"}), + ); + }); + + test("falls back to setOperator on a wallet without the new surface", async () => { + mockClient.initiateOperatorTransfer.mockRejectedValue( + new Error("Execution reverted for an unknown reason."), + ); + + await action.execute({ + validator: "0xValidatorWallet", + operator: "0xNewOperator", + stakingAddress: "0xStaking", + }); + + expect(mockClient.setOperator).toHaveBeenCalledWith({ + validator: "0xValidatorWallet", + operator: "0xNewOperator", + }); + expect(action["succeedSpinner"]).toHaveBeenCalledWith("Operator updated!", expect.any(Object)); + }); + }); + test("handles errors", async () => { mockClient.setOperator.mockRejectedValue(new Error("set operator failed"));