Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 14 additions & 11 deletions src/commands/staking/validatorDeposit.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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);
Expand Down
29 changes: 16 additions & 13 deletions src/commands/staking/validatorExit.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
89 changes: 86 additions & 3 deletions tests/actions/staking.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand Down
Loading