diff --git a/src/commands/staking/validatorDeposit.ts b/src/commands/staking/validatorDeposit.ts index 6a71fd12..fd2fa73d 100644 --- a/src/commands/staking/validatorDeposit.ts +++ b/src/commands/staking/validatorDeposit.ts @@ -24,25 +24,28 @@ export class ValidatorDepositAction extends StakingAction { const amount = this.parseAmount(options.amount); const validatorWallet = options.validator as Address; - const {walletClient, publicClient} = await this.getViemClients(options); + // Route through the SDK's staking action 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 action forwards to the + // ValidatorWallet's own `validatorDeposit`, preserving msg.sender == + // ValidatorWallet when it re-enters Staking. + const client = await this.getStakingClient(options); this.setSpinnerText(`Depositing ${this.formatAmount(amount)} to validator ${validatorWallet}...`); - const hash = await walletClient.writeContract({ - address: validatorWallet, - abi: abi.VALIDATOR_WALLET_ABI, - functionName: "validatorDeposit", - value: amount, + const result = await client.validatorDeposit({ + validator: validatorWallet, + amount, }); - const receipt = await publicClient.waitForTransactionReceipt({hash}); - const output = { - transactionHash: receipt.transactionHash, + transactionHash: result.transactionHash, validator: validatorWallet, amount: this.formatAmount(amount), - blockNumber: receipt.blockNumber.toString(), - gasUsed: receipt.gasUsed.toString(), + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), }; this.succeedSpinner("Deposit successful!", output); diff --git a/src/commands/staking/validatorExit.ts b/src/commands/staking/validatorExit.ts index 6829cbdd..44fc2f51 100644 --- a/src/commands/staking/validatorExit.ts +++ b/src/commands/staking/validatorExit.ts @@ -31,30 +31,33 @@ export class ValidatorExitAction extends StakingAction { } const validatorWallet = options.validator as Address; - const {walletClient, publicClient} = await this.getViemClients(options); + + // Route through the SDK's staking action 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 action forwards to the + // ValidatorWallet's own `validatorExit`, preserving msg.sender == + // ValidatorWallet when it re-enters Staking. + const client = await this.getStakingClient(options); this.setSpinnerText(`Exiting validator ${validatorWallet} with ${shares} shares...`); - const hash = await walletClient.writeContract({ - address: validatorWallet, - abi: abi.VALIDATOR_WALLET_ABI, - functionName: "validatorExit", - args: [shares], + const result = await client.validatorExit({ + validator: validatorWallet, + shares, }); - const receipt = await publicClient.waitForTransactionReceipt({hash}); - // Check epoch to determine note - const readClient = await this.getReadOnlyStakingClient(options); - const epochInfo = await readClient.getEpochInfo(); + const epochInfo = await client.getEpochInfo(); const isEpochZero = epochInfo.currentEpoch === 0n; const output = { - transactionHash: receipt.transactionHash, + transactionHash: result.transactionHash, validator: validatorWallet, sharesWithdrawn: shares.toString(), - blockNumber: receipt.blockNumber.toString(), - gasUsed: receipt.gasUsed.toString(), + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), note: isEpochZero ? "Epoch 0: Withdrawal claimable immediately" : "Withdrawal will be claimable after the unbonding period", diff --git a/tests/actions/staking.test.ts b/tests/actions/staking.test.ts index a67ce704..0df859e1 100644 --- a/tests/actions/staking.test.ts +++ b/tests/actions/staking.test.ts @@ -150,9 +150,92 @@ describe("ValidatorJoinAction", () => { }); }); -// ValidatorDepositAction, ValidatorExitAction, ValidatorClaimAction tests -// are covered by command-level tests. These actions now use viem directly -// to call ValidatorWallet contracts and require complex viem mocking. +// ValidatorDepositAction / ValidatorExitAction: keystore path goes through the +// SDK staking client (client.validatorDeposit / client.validatorExit), matching +// every other staking command. Previously these two used raw viem +// writeContract, which fails on the GenLayer consensus RPC (no EIP-1559 fee +// support) — see fix in validatorDeposit.ts / validatorExit.ts. +describe("ValidatorDepositAction", () => { + let action: ValidatorDepositAction; + + beforeEach(() => { + vi.clearAllMocks(); + action = new ValidatorDepositAction(); + setupActionMocks(action); + mockClient.validatorDeposit.mockResolvedValue(mockTxResult); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + 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)); + }); + + test("handles errors", async () => { + mockClient.validatorDeposit.mockRejectedValue(new Error("deposit failed")); + + await action.execute({validator: "0xValidatorWallet", amount: "10gen", stakingAddress: "0xStaking"}); + + expect(action["failSpinner"]).toHaveBeenCalledWith("Failed to make deposit", "deposit failed"); + }); +}); + +describe("ValidatorExitAction", () => { + let action: ValidatorExitAction; + + beforeEach(() => { + vi.clearAllMocks(); + action = new ValidatorExitAction(); + setupActionMocks(action); + mockClient.validatorExit.mockResolvedValue(mockTxResult); + mockClient.getEpochInfo.mockResolvedValue(mockEpochInfo); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + 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)); + }); + + test("rejects a non-positive shares value before calling the client", async () => { + await action.execute({validator: "0xValidatorWallet", shares: "0", stakingAddress: "0xStaking"}); + + expect(mockClient.validatorExit).not.toHaveBeenCalled(); + expect(action["failSpinner"]).toHaveBeenCalledWith( + 'Invalid shares value: "0". Must be a positive whole number.', + ); + }); + + test("handles errors", async () => { + mockClient.validatorExit.mockRejectedValue(new Error("exit failed")); + + await action.execute({validator: "0xValidatorWallet", shares: "50", stakingAddress: "0xStaking"}); + + expect(action["failSpinner"]).toHaveBeenCalledWith("Failed to exit", "exit failed"); + }); +}); describe("DelegatorJoinAction", () => { let action: DelegatorJoinAction;