Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@
"dotenv": "^17.0.0",
"ethers": "^6.13.4",
"fs-extra": "^11.3.0",
"genlayer-js": "^0.21.3",
"genlayer-js": "^0.21.4",
"inquirer": "^12.0.0",
"keytar": "^7.9.0",
"node-fetch": "^3.0.0",
Expand Down
52 changes: 48 additions & 4 deletions src/commands/transactions/appeal.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import {TransactionHash} from "genlayer-js/types";
import {parseStakingAmount, formatStakingAmount} from "genlayer-js";
import {BaseAction} from "../../lib/actions/BaseAction";

export interface AppealOptions {
rpc?: string;
bond?: string;
}

export interface AppealBondOptions {
rpc?: string;
}

export class AppealAction extends BaseAction {
Expand All @@ -13,27 +19,65 @@ export class AppealAction extends BaseAction {
async appeal({
txId,
rpc,
bond,
}: {
txId: TransactionHash;
rpc?: string;
bond?: string;
}): Promise<void> {
const client = await this.getClient(rpc);
await client.initializeConsensusSmartContract();
this.startSpinner(`Appealing transaction ${txId}...`);

try {
let value: bigint | undefined;
if (bond) {
value = parseStakingAmount(bond);
} else {
this.startSpinner("Calculating appeal bond...");
try {
value = await client.getMinAppealBond({txId});
this.stopSpinner();
this.logInfo(`Appeal bond: ${formatStakingAmount(value)}`);
} catch {
this.stopSpinner();
value = undefined;
}
}

await this.confirmPrompt("Proceed with appeal?");

this.startSpinner(`Appealing transaction ${txId}...`);
const hash = await client.appealTransaction({
txId,
value,
});

this.setSpinnerText("Waiting for finalization...");
const result = await client.waitForTransactionReceipt({
hash,
retries: 100,
interval: 5000,
});
this.succeedSpinner("Appeal operation successfully executed", result);
this.succeedSpinner("Appeal successfully executed", result);
} catch (error) {
this.failSpinner("Error during appeal operation", error);
}
}
}

async appealBond({
txId,
rpc,
}: {
txId: TransactionHash;
rpc?: string;
}): Promise<void> {
const client = await this.getClient(rpc, true);
this.startSpinner(`Calculating appeal bond for ${txId}...`);

try {
const bond = await client.getMinAppealBond({txId});
this.succeedSpinner(`Minimum appeal bond: ${formatStakingAmount(bond)}`);
} catch (error) {
this.failSpinner("Error calculating appeal bond", error);
}
}
}
20 changes: 15 additions & 5 deletions src/commands/transactions/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {Command} from "commander";
import {TransactionStatus, TransactionHash} from "genlayer-js/types";
import {ReceiptAction, ReceiptOptions} from "./receipt";
import {AppealAction, AppealOptions} from "./appeal";
import {AppealAction, AppealOptions, AppealBondOptions} from "./appeal";

function parseIntOption(value: string, fallback: number): number {
const parsed = parseInt(value, 10);
Expand All @@ -10,7 +10,7 @@ function parseIntOption(value: string, fallback: number): number {

export function initializeTransactionsCommands(program: Command) {
const validStatuses = Object.values(TransactionStatus).join(", ");

program
.command("receipt <txId>")
.description("Get transaction receipt by hash")
Expand All @@ -22,18 +22,28 @@ export function initializeTransactionsCommands(program: Command) {
.option("--stderr", "Print only stderr from the receipt")
.action(async (txId: TransactionHash, options: ReceiptOptions) => {
const receiptAction = new ReceiptAction();

await receiptAction.receipt({txId, ...options});
})
})

program
.command("appeal <txId>")
.description("Appeal a transaction by its hash")
.option("--bond <amount>", "Appeal bond amount (e.g. 500gen, 0.5gen). Auto-calculated if omitted")
.option("--rpc <rpcUrl>", "RPC URL for the network")
.action(async (txId: TransactionHash, options: AppealOptions) => {
const appealAction = new AppealAction();
await appealAction.appeal({txId, ...options});
});

program
.command("appeal-bond <txId>")
.description("Show minimum appeal bond required for a transaction")
.option("--rpc <rpcUrl>", "RPC URL for the network")
.action(async (txId: TransactionHash, options: AppealBondOptions) => {
const appealAction = new AppealAction();
await appealAction.appealBond({txId, ...options});
});

return program;
}
}
98 changes: 70 additions & 28 deletions tests/actions/appeal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,22 @@ import {createClient, createAccount} from "genlayer-js";
import type {TransactionHash} from "genlayer-js/types";
import {AppealAction} from "../../src/commands/transactions/appeal";

vi.mock("genlayer-js");
vi.mock("genlayer-js", async (importOriginal) => {
const actual = await importOriginal<typeof import("genlayer-js")>();
return {
...actual,
createClient: vi.fn(),
createAccount: vi.fn(),
};
});

describe("AppealAction", () => {
let appealAction: AppealAction;
const mockClient = {
appealTransaction: vi.fn(),
waitForTransactionReceipt: vi.fn(),
initializeConsensusSmartContract: vi.fn(),
getMinAppealBond: vi.fn(),
};

const mockPrivateKey = "mocked_private_key";
Expand All @@ -26,31 +34,65 @@ describe("AppealAction", () => {
vi.spyOn(appealAction as any, "startSpinner").mockImplementation(() => {});
vi.spyOn(appealAction as any, "succeedSpinner").mockImplementation(() => {});
vi.spyOn(appealAction as any, "failSpinner").mockImplementation(() => {});
vi.spyOn(appealAction as any, "stopSpinner").mockImplementation(() => {});
vi.spyOn(appealAction as any, "setSpinnerText").mockImplementation(() => {});
vi.spyOn(appealAction as any, "logInfo").mockImplementation(() => {});
vi.spyOn(appealAction as any, "confirmPrompt").mockResolvedValue(undefined);
});

afterEach(() => {
vi.restoreAllMocks();
});

test("calls appealTransaction successfully", async () => {
test("auto-calculates bond and appeals successfully", async () => {
const mockReceipt = {status: "success"};

vi.mocked(mockClient.getMinAppealBond).mockResolvedValue(500000000000000000000n);
vi.mocked(mockClient.appealTransaction).mockResolvedValue("0xhash");
vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue(mockReceipt);

await appealAction.appeal({
txId: mockTxId,
});
await appealAction.appeal({txId: mockTxId});

expect(mockClient.getMinAppealBond).toHaveBeenCalledWith({txId: mockTxId});
expect(mockClient.appealTransaction).toHaveBeenCalledWith({
txId: mockTxId,
value: 500000000000000000000n,
});
expect(appealAction["succeedSpinner"]).toHaveBeenCalledWith(
"Appeal operation successfully executed",
"Appeal successfully executed",
mockReceipt,
);
});

test("uses explicit bond when provided", async () => {
const mockReceipt = {status: "success"};
vi.mocked(mockClient.appealTransaction).mockResolvedValue("0xhash");
vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue(mockReceipt);

await appealAction.appeal({txId: mockTxId, bond: "100gen"});

expect(mockClient.getMinAppealBond).not.toHaveBeenCalled();
expect(mockClient.appealTransaction).toHaveBeenCalledWith({
txId: mockTxId,
value: 100000000000000000000n,
});
});

test("falls back to undefined value when bond calculation fails", async () => {
const mockReceipt = {status: "success"};
vi.mocked(mockClient.getMinAppealBond).mockRejectedValue(new Error("not supported"));
vi.mocked(mockClient.appealTransaction).mockResolvedValue("0xhash");
vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue(mockReceipt);

await appealAction.appeal({txId: mockTxId});

expect(mockClient.appealTransaction).toHaveBeenCalledWith({
txId: mockTxId,
value: undefined,
});
});

test("handles appealTransaction errors", async () => {
vi.mocked(mockClient.getMinAppealBond).mockResolvedValue(0n);
vi.mocked(mockClient.appealTransaction).mockRejectedValue(new Error("Mocked appeal error"));

await appealAction.appeal({txId: mockTxId});
Expand All @@ -64,36 +106,36 @@ describe("AppealAction", () => {
test("uses custom RPC URL for appeal operations", async () => {
const rpcUrl = "https://custom-rpc-url.com";
const mockReceipt = {status: "success"};

vi.mocked(mockClient.getMinAppealBond).mockResolvedValue(0n);
vi.mocked(mockClient.appealTransaction).mockResolvedValue("0xhash");
vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue(mockReceipt);

await appealAction.appeal({
txId: mockTxId,
rpc: rpcUrl,
});
await appealAction.appeal({txId: mockTxId, rpc: rpcUrl});

expect(createClient).toHaveBeenCalledWith(
expect.objectContaining({
endpoint: rpcUrl,
}),
expect.objectContaining({endpoint: rpcUrl}),
);
expect(mockClient.appealTransaction).toHaveBeenCalledWith({
txId: mockTxId,
});
});

test("appealBond returns minimum bond", async () => {
vi.mocked(mockClient.getMinAppealBond).mockResolvedValue(500000000000000000000n);

await appealAction.appealBond({txId: mockTxId});

expect(mockClient.getMinAppealBond).toHaveBeenCalledWith({txId: mockTxId});
expect(appealAction["succeedSpinner"]).toHaveBeenCalledWith(
"Appeal operation successfully executed",
mockReceipt,
`Minimum appeal bond: 500 GEN`,
);
});

test("initializes consensus smart contract before appeal", async () => {
const mockReceipt = {status: "success"};

vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue(mockReceipt);
test("appealBond handles errors", async () => {
vi.mocked(mockClient.getMinAppealBond).mockRejectedValue(new Error("not supported"));

await appealAction.appeal({txId: mockTxId});
await appealAction.appealBond({txId: mockTxId});

expect(mockClient.initializeConsensusSmartContract).toHaveBeenCalledTimes(1);
expect(mockClient.appealTransaction).toHaveBeenCalled();
expect(appealAction["failSpinner"]).toHaveBeenCalledWith(
"Error calculating appeal bond",
expect.any(Error),
);
});
});
});
41 changes: 40 additions & 1 deletion tests/commands/appeal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ describe("appeal command", () => {
});
});

test("AppealAction.appeal is called with --bond option", async () => {
program.parse(["node", "test", "appeal", mockTxId, "--bond", "500gen"]);
expect(AppealAction.prototype.appeal).toHaveBeenCalledWith({
txId: mockTxId,
bond: "500gen",
});
});

test("AppealAction is instantiated when the appeal command is executed", async () => {
program.parse(["node", "test", "appeal", mockTxId]);
expect(AppealAction).toHaveBeenCalledTimes(1);
Expand All @@ -55,4 +63,35 @@ describe("appeal command", () => {
program.parse(["node", "test", "appeal", mockTxId, "--invalid-option"]),
).toThrowError("error: unknown option '--invalid-option'");
});
});
});

describe("appeal-bond command", () => {
let program: Command;
const mockTxId = "0x1234567890123456789012345678901234567890123456789012345678901234";

beforeEach(() => {
program = new Command();
initializeTransactionsCommands(program);
vi.clearAllMocks();
});

afterEach(() => {
vi.restoreAllMocks();
});

test("AppealAction.appealBond is called with txId", async () => {
program.parse(["node", "test", "appeal-bond", mockTxId]);
expect(AppealAction).toHaveBeenCalledTimes(1);
expect(AppealAction.prototype.appealBond).toHaveBeenCalledWith({
txId: mockTxId,
});
});

test("AppealAction.appealBond is called with custom RPC URL", async () => {
program.parse(["node", "test", "appeal-bond", mockTxId, "--rpc", "https://custom.com"]);
expect(AppealAction.prototype.appealBond).toHaveBeenCalledWith({
txId: mockTxId,
rpc: "https://custom.com",
});
});
});
Loading