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
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.22.1",
"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