From 51bfd3bd755af3229e2b867045e23a2499f84fd7 Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 9 Jul 2026 21:59:24 +0100 Subject: [PATCH] fix(staking): route set-operator/validator-claim/set-identity/trace through the SDK The keystore paths of `staking set-operator`, `staking validator-claim`, `staking set-identity` and `transactions trace` used raw viem `walletClient.writeContract` / `client.request`. viem negotiates its own EIP-1559 fee/tx-type against the GenLayer consensus RPC, which has no EIP-1559 fee support, so those writes fail. This is the same latently-broken class already fixed for validator-deposit/exit. Route each keystore path through the genlayer-js client instead: - set-operator -> client.setOperator({validator, operator}) - validator-claim-> client.validatorClaim({validator}) (surfaces claimedAmount) - set-identity -> client.setIdentity({validator, moniker, ...}); the SDK now owns extraCid encoding (drop the hand-rolled toHex/TextEncoder) - trace -> client.debugTraceTransaction({hash, round}) (typed action, same wire call as gen_dbg_traceTransaction) setOperator/setIdentity exist on the client at runtime (route through the SDK's legacy-tx executeWrite) but are missing from the installed genlayer-js StakingActions .d.ts; a narrow cast bridges that type gap. Browser-wallet paths are unchanged (the bridge signs, so they were already correct). With every caller gone, StakingAction.getViemClients is deleted along with its now-unused viem imports. Add keystore-path tests asserting each command calls the SDK client, and a trace test using debugTraceTransaction. --- src/commands/staking/StakingAction.ts | 52 +--------- src/commands/staking/setIdentity.ts | 60 ++++++----- src/commands/staking/setOperator.ts | 36 ++++--- src/commands/staking/validatorClaim.ts | 28 ++--- src/commands/transactions/trace.ts | 7 +- tests/actions/staking.test.ts | 136 +++++++++++++++++++++++-- tests/actions/trace.test.ts | 71 +++++++++++++ 7 files changed, 276 insertions(+), 114 deletions(-) create mode 100644 tests/actions/trace.test.ts diff --git a/src/commands/staking/StakingAction.ts b/src/commands/staking/StakingAction.ts index 40644785..714f843d 100644 --- a/src/commands/staking/StakingAction.ts +++ b/src/commands/staking/StakingAction.ts @@ -3,17 +3,7 @@ import {createClient, createAccount, formatStakingAmount, parseStakingAmount, ab import type {GenLayerClient, GenLayerChain, Address} from "genlayer-js/types"; import {readFileSync, existsSync} from "fs"; import {ethers, ZeroAddress} from "ethers"; -import { - createPublicClient, - createWalletClient, - http, - type PublicClient, - type WalletClient, - type Chain, - type Account, - type TransactionReceipt, -} from "viem"; -import {privateKeyToAccount} from "viem/accounts"; +import {createPublicClient, http} from "viem"; import {glHttpConfig, type BrowserSession} from "../../lib/wallet/browserSend"; import {resolveBrowserWalletSession} from "../../lib/wallet/sessionResolver"; @@ -252,46 +242,6 @@ export class StakingAction extends BaseAction { return (addr.startsWith("0x") ? addr : `0x${addr}`) as Address; } - /** - * Get viem clients for direct contract interactions (e.g., ValidatorWallet calls) - * Future: can be extended to support hardware wallets - */ - protected async getViemClients(config: StakingConfig): Promise<{ - walletClient: WalletClient; - publicClient: PublicClient; - signerAddress: Address; - }> { - if (config.account) { - this.accountOverride = config.account; - } - if (config.password) { - this._passwordOverride = config.password; - } - - const network = this.getNetwork(config); - const rpcUrl = config.rpc || network.rpcUrls.default.http[0]; - - const privateKey = await this.getPrivateKeyForStaking(); - const account = privateKeyToAccount(privateKey as `0x${string}`); - - const publicClient = createPublicClient({ - chain: network, - transport: http(rpcUrl, glHttpConfig), - }); - - const walletClient = createWalletClient({ - chain: network, - transport: http(rpcUrl, glHttpConfig), - account, - }); - - return { - walletClient, - publicClient, - signerAddress: account.address as Address, - }; - } - /** * Get all validators by traversing the validator tree. * This finds ALL validators including those not yet active/primed. diff --git a/src/commands/staking/setIdentity.ts b/src/commands/staking/setIdentity.ts index ee5f1075..d60058f3 100644 --- a/src/commands/staking/setIdentity.ts +++ b/src/commands/staking/setIdentity.ts @@ -1,7 +1,11 @@ import {StakingAction, StakingConfig} from "./StakingAction"; -import type {Address} from "genlayer-js/types"; -import {abi} from "genlayer-js"; -import {toHex} from "viem"; +import type { + Address, + GenLayerClient, + GenLayerChain, + SetIdentityOptions as SdkSetIdentityOptions, + StakingTransactionResult, +} from "genlayer-js/types"; import {buildSetIdentityTx} from "../../lib/wallet/txBuilders"; export interface SetIdentityOptions extends StakingConfig { @@ -31,38 +35,40 @@ export class SetIdentityAction extends StakingAction { try { const validatorWallet = options.validator as Address; - const {walletClient, publicClient} = await this.getViemClients(options); + + // Route through the SDK staking client rather than a raw viem + // writeContract. The SDK's executeWrite pins `type: "legacy"` and does + // manual nonce/gas + sign + sendRawTransaction, which the GenLayer + // consensus RPC requires (it has no EIP-1559 fee support, so viem's + // default fee/tx-type negotiation fails). The SDK owns the extraCid + // encoding (hex passthrough vs UTF-8 -> hex). `setIdentity` exists on the + // client at runtime but is missing from the installed genlayer-js + // StakingActions .d.ts — cast to bridge that type gap. + const client = (await this.getStakingClient(options)) as GenLayerClient & { + setIdentity(o: SdkSetIdentityOptions): Promise; + }; this.setSpinnerText(`Setting identity for ${validatorWallet}...`); - // Convert extraCid string to bytes (hex) - const extraCidBytes = options.extraCid ? toHex(new TextEncoder().encode(options.extraCid)) : "0x"; - - const hash = await walletClient.writeContract({ - address: validatorWallet, - abi: abi.VALIDATOR_WALLET_ABI, - functionName: "setIdentity", - args: [ - options.moniker, - options.logoUri || "", - options.website || "", - options.description || "", - options.email || "", - options.twitter || "", - options.telegram || "", - options.github || "", - extraCidBytes, - ], + const result = await client.setIdentity({ + validator: validatorWallet, + moniker: options.moniker, + logoUri: options.logoUri, + website: options.website, + description: options.description, + email: options.email, + twitter: options.twitter, + telegram: options.telegram, + github: options.github, + extraCid: options.extraCid, }); - const receipt = await publicClient.waitForTransactionReceipt({hash}); - const output: Record = { - transactionHash: receipt.transactionHash, + transactionHash: result.transactionHash, validator: validatorWallet, moniker: options.moniker, - blockNumber: receipt.blockNumber.toString(), - gasUsed: receipt.gasUsed.toString(), + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), }; // Add optional fields that were set diff --git a/src/commands/staking/setOperator.ts b/src/commands/staking/setOperator.ts index 5775a76c..7566d3b0 100644 --- a/src/commands/staking/setOperator.ts +++ b/src/commands/staking/setOperator.ts @@ -1,5 +1,11 @@ import {StakingAction, StakingConfig} from "./StakingAction"; -import type {Address} from "genlayer-js/types"; +import type { + Address, + GenLayerClient, + GenLayerChain, + SetOperatorOptions as SdkSetOperatorOptions, + StakingTransactionResult, +} from "genlayer-js/types"; import {abi} from "genlayer-js"; import {buildTx} from "../../lib/wallet/txBuilders"; @@ -22,25 +28,31 @@ export class SetOperatorAction extends StakingAction { try { const validatorWallet = options.validator as Address; - const {walletClient, publicClient} = await this.getViemClients(options); + + // Route through the SDK staking client rather than a raw viem + // writeContract. The SDK's executeWrite pins `type: "legacy"` and does + // manual nonce/gas + sign + sendRawTransaction, which the GenLayer + // consensus RPC requires (it has no EIP-1559 fee support, so viem's + // default fee/tx-type negotiation fails). `setOperator` exists on the + // client at runtime but is missing from the installed genlayer-js + // StakingActions .d.ts — cast to bridge that type gap. + const client = (await this.getStakingClient(options)) as GenLayerClient & { + setOperator(o: SdkSetOperatorOptions): Promise; + }; this.setSpinnerText(`Setting operator to ${options.operator}...`); - const hash = await walletClient.writeContract({ - address: validatorWallet, - abi: abi.VALIDATOR_WALLET_ABI, - functionName: "setOperator", - args: [options.operator as Address], + const result = await client.setOperator({ + validator: validatorWallet, + operator: options.operator as Address, }); - const receipt = await publicClient.waitForTransactionReceipt({hash}); - const output = { - transactionHash: receipt.transactionHash, + transactionHash: result.transactionHash, validator: validatorWallet, newOperator: options.operator, - blockNumber: receipt.blockNumber.toString(), - gasUsed: receipt.gasUsed.toString(), + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), }; this.succeedSpinner("Operator updated!", output); diff --git a/src/commands/staking/validatorClaim.ts b/src/commands/staking/validatorClaim.ts index e3c8a5ac..b3e9afca 100644 --- a/src/commands/staking/validatorClaim.ts +++ b/src/commands/staking/validatorClaim.ts @@ -21,25 +21,29 @@ export class ValidatorClaimAction extends StakingAction { try { const validatorWallet = options.validator as Address; - const {walletClient, publicClient} = await this.getViemClients(options); - this.setSpinnerText(`Claiming for validator ${validatorWallet}...`); + // Route through the SDK staking client rather than a raw viem + // writeContract. The SDK's executeWrite pins `type: "legacy"` and does + // manual nonce/gas + sign + sendRawTransaction, which the GenLayer + // consensus RPC requires (it has no EIP-1559 fee support, so viem's + // default fee/tx-type negotiation fails). + const client = await this.getStakingClient(options); - const hash = await walletClient.writeContract({ - address: validatorWallet, - abi: abi.VALIDATOR_WALLET_ABI, - functionName: "validatorClaim", - }); + this.setSpinnerText(`Claiming for validator ${validatorWallet}...`); - const receipt = await publicClient.waitForTransactionReceipt({hash}); + const result = await client.validatorClaim({validator: validatorWallet}); - const output = { - transactionHash: receipt.transactionHash, + const output: Record = { + transactionHash: result.transactionHash, validator: validatorWallet, - blockNumber: receipt.blockNumber.toString(), - gasUsed: receipt.gasUsed.toString(), + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), }; + if (result.claimedAmount !== undefined) { + output.claimedAmount = this.formatAmount(result.claimedAmount); + } + this.succeedSpinner("Claim successful!", output); } catch (error: any) { this.failSpinner("Failed to claim", error.message || error); diff --git a/src/commands/transactions/trace.ts b/src/commands/transactions/trace.ts index 13db39e9..6125bafa 100644 --- a/src/commands/transactions/trace.ts +++ b/src/commands/transactions/trace.ts @@ -23,12 +23,7 @@ export class TraceAction extends BaseAction { this.startSpinner(`Fetching execution trace for ${txId} (round: ${round})...`); try { - const result = await client.request({ - method: "gen_dbg_traceTransaction" as any, - params: [{txID: txId, round}], - }); - - const trace = (result as any); + const trace = await client.debugTraceTransaction({hash: txId, round}); if (!trace) { this.failSpinner("No trace found", `No execution trace found for transaction ${txId}`); return; diff --git a/tests/actions/staking.test.ts b/tests/actions/staking.test.ts index 6606a6fa..25a394e1 100644 --- a/tests/actions/staking.test.ts +++ b/tests/actions/staking.test.ts @@ -7,6 +7,7 @@ import {DelegatorJoinAction} from "../../src/commands/staking/delegatorJoin"; import {DelegatorExitAction} from "../../src/commands/staking/delegatorExit"; import {DelegatorClaimAction} from "../../src/commands/staking/delegatorClaim"; import {SetOperatorAction} from "../../src/commands/staking/setOperator"; +import {SetIdentityAction} from "../../src/commands/staking/setIdentity"; import {StakingInfoAction} from "../../src/commands/staking/stakingInfo"; // Mock genlayer-js @@ -83,6 +84,8 @@ const mockClient = { validatorDeposit: vi.fn(), validatorExit: vi.fn(), validatorClaim: vi.fn(), + setOperator: vi.fn(), + setIdentity: vi.fn(), delegatorJoin: vi.fn(), delegatorExit: vi.fn(), delegatorClaim: vi.fn(), @@ -170,15 +173,12 @@ describe("ValidatorDepositAction", () => { }); test("deposits to validator via the SDK client (not raw viem)", async () => { - const getViemSpy = vi.spyOn(action as any, "getViemClients"); - await action.execute({validator: "0xValidatorWallet", amount: "10gen", stakingAddress: "0xStaking"}); expect(mockClient.validatorDeposit).toHaveBeenCalledWith({ validator: "0xValidatorWallet", amount: expect.any(BigInt), }); - expect(getViemSpy).not.toHaveBeenCalled(); expect(action["succeedSpinner"]).toHaveBeenCalledWith("Deposit successful!", expect.any(Object)); }); @@ -207,15 +207,12 @@ describe("ValidatorExitAction", () => { }); test("exits validator via the SDK client (not raw viem)", async () => { - const getViemSpy = vi.spyOn(action as any, "getViemClients"); - await action.execute({validator: "0xValidatorWallet", shares: "50", stakingAddress: "0xStaking"}); expect(mockClient.validatorExit).toHaveBeenCalledWith({ validator: "0xValidatorWallet", shares: 50n, }); - expect(getViemSpy).not.toHaveBeenCalled(); expect(action["succeedSpinner"]).toHaveBeenCalledWith("Exit initiated successfully!", expect.any(Object)); }); @@ -237,6 +234,133 @@ describe("ValidatorExitAction", () => { }); }); +// SetOperatorAction / ValidatorClaimAction / SetIdentityAction: keystore path +// goes through the SDK staking client (client.setOperator / validatorClaim / +// setIdentity), matching every other staking write. Previously these used raw +// viem writeContract, which fails on the GenLayer consensus RPC (no EIP-1559 +// fee support). getViemClients has been removed entirely, so routing through +// the SDK is the only path. +describe("SetOperatorAction", () => { + let action: SetOperatorAction; + + beforeEach(() => { + vi.clearAllMocks(); + action = new SetOperatorAction(); + setupActionMocks(action); + mockClient.setOperator.mockResolvedValue(mockTxResult); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("sets operator via the SDK client (not raw viem)", async () => { + 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")); + + await action.execute({ + validator: "0xValidatorWallet", + operator: "0xNewOperator", + stakingAddress: "0xStaking", + }); + + expect(action["failSpinner"]).toHaveBeenCalledWith("Failed to set operator", "set operator failed"); + }); +}); + +describe("ValidatorClaimAction", () => { + let action: ValidatorClaimAction; + + beforeEach(() => { + vi.clearAllMocks(); + action = new ValidatorClaimAction(); + setupActionMocks(action); + mockClient.validatorClaim.mockResolvedValue({...mockTxResult, claimedAmount: 5n * BigInt(1e18)}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("claims via the SDK client (not raw viem) and surfaces claimedAmount", async () => { + await action.execute({validator: "0xValidatorWallet", stakingAddress: "0xStaking"}); + + expect(mockClient.validatorClaim).toHaveBeenCalledWith({validator: "0xValidatorWallet"}); + expect(action["succeedSpinner"]).toHaveBeenCalledWith( + "Claim successful!", + expect.objectContaining({claimedAmount: expect.any(String)}), + ); + }); + + test("handles errors", async () => { + mockClient.validatorClaim.mockRejectedValue(new Error("claim failed")); + + await action.execute({validator: "0xValidatorWallet", stakingAddress: "0xStaking"}); + + expect(action["failSpinner"]).toHaveBeenCalledWith("Failed to claim", "claim failed"); + }); +}); + +describe("SetIdentityAction", () => { + let action: SetIdentityAction; + + beforeEach(() => { + vi.clearAllMocks(); + action = new SetIdentityAction(); + setupActionMocks(action); + mockClient.setIdentity.mockResolvedValue(mockTxResult); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("sets identity via the SDK client (SDK owns extraCid encoding)", async () => { + await action.execute({ + validator: "0xValidatorWallet", + moniker: "MyValidator", + website: "https://example.com", + extraCid: "ipfs://cid", + stakingAddress: "0xStaking", + }); + + expect(mockClient.setIdentity).toHaveBeenCalledWith({ + validator: "0xValidatorWallet", + moniker: "MyValidator", + logoUri: undefined, + website: "https://example.com", + description: undefined, + email: undefined, + twitter: undefined, + telegram: undefined, + github: undefined, + extraCid: "ipfs://cid", + }); + expect(action["succeedSpinner"]).toHaveBeenCalledWith("Validator identity set!", expect.any(Object)); + }); + + test("handles errors", async () => { + mockClient.setIdentity.mockRejectedValue(new Error("set identity failed")); + + await action.execute({validator: "0xValidatorWallet", moniker: "MyValidator", stakingAddress: "0xStaking"}); + + expect(action["failSpinner"]).toHaveBeenCalledWith("Failed to set identity", "set identity failed"); + }); +}); + describe("DelegatorJoinAction", () => { let action: DelegatorJoinAction; diff --git a/tests/actions/trace.test.ts b/tests/actions/trace.test.ts new file mode 100644 index 00000000..04fc00ab --- /dev/null +++ b/tests/actions/trace.test.ts @@ -0,0 +1,71 @@ +import {describe, test, vi, beforeEach, afterEach, expect} from "vitest"; +import {TraceAction} from "../../src/commands/transactions/trace"; +import type {TransactionHash} from "genlayer-js/types"; + +// TraceAction routes through the typed SDK action client.debugTraceTransaction +// (same wire call as the former raw client.request({method: +// "gen_dbg_traceTransaction"})). +const mockClient = { + debugTraceTransaction: vi.fn(), +}; + +function setupActionMocks(action: any) { + vi.spyOn(action as any, "getClient").mockResolvedValue(mockClient); + vi.spyOn(action as any, "startSpinner").mockImplementation(() => {}); + vi.spyOn(action as any, "succeedSpinner").mockImplementation(() => {}); + vi.spyOn(action as any, "failSpinner").mockImplementation(() => {}); +} + +const txId = "0xdeadbeef" as TransactionHash; + +describe("TraceAction", () => { + let action: TraceAction; + + beforeEach(() => { + vi.clearAllMocks(); + action = new TraceAction(); + setupActionMocks(action); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("fetches the trace via the typed SDK action", async () => { + const trace = {calls: [], result: "ok"}; + mockClient.debugTraceTransaction.mockResolvedValue(trace); + + await action.trace({txId, round: 2}); + + expect(mockClient.debugTraceTransaction).toHaveBeenCalledWith({hash: txId, round: 2}); + expect(action["succeedSpinner"]).toHaveBeenCalledWith("Execution trace retrieved", trace); + }); + + test("defaults round to 0", async () => { + mockClient.debugTraceTransaction.mockResolvedValue({}); + + await action.trace({txId}); + + expect(mockClient.debugTraceTransaction).toHaveBeenCalledWith({hash: txId, round: 0}); + }); + + test("reports when no trace is found", async () => { + mockClient.debugTraceTransaction.mockResolvedValue(null); + + await action.trace({txId}); + + expect(action["failSpinner"]).toHaveBeenCalledWith( + "No trace found", + `No execution trace found for transaction ${txId}`, + ); + }); + + test("handles errors", async () => { + const err = new Error("trace boom"); + mockClient.debugTraceTransaction.mockRejectedValue(err); + + await action.trace({txId}); + + expect(action["failSpinner"]).toHaveBeenCalledWith("Error retrieving execution trace", err); + }); +});