diff --git a/package-lock.json b/package-lock.json index 23d45444..5cc37088 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,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", @@ -5581,9 +5581,9 @@ } }, "node_modules/genlayer-js": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/genlayer-js/-/genlayer-js-0.21.3.tgz", - "integrity": "sha512-kd/fPDMZ6LoBocVmd4j2VaL12UkNNlQcBJ8mmJLVtrUEZvyNaXfkvDg3L4Lz9lGfgKEAkTsSF7RLfAVKyLWQzg==", + "version": "0.22.1", + "resolved": "https://registry.npmjs.org/genlayer-js/-/genlayer-js-0.22.1.tgz", + "integrity": "sha512-cMWX36PiElHyqsnyLsrD9eY1aRMhpNfsmQ4t8N8HzvvfoCOpGBT6j2yyjcmqEzUeIOSvEBKwvih88p8bA4gLrA==", "license": "MIT", "dependencies": { "eslint-plugin-import": "^2.30.0", diff --git a/package.json b/package.json index d8322e63..9814a33d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/commands/transactions/appeal.ts b/src/commands/transactions/appeal.ts index e2532c60..0c4d9082 100644 --- a/src/commands/transactions/appeal.ts +++ b/src/commands/transactions/appeal.ts @@ -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 { @@ -13,27 +19,65 @@ export class AppealAction extends BaseAction { async appeal({ txId, rpc, + bond, }: { txId: TransactionHash; rpc?: string; + bond?: string; }): Promise { 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); } } -} \ No newline at end of file + + async appealBond({ + txId, + rpc, + }: { + txId: TransactionHash; + rpc?: string; + }): Promise { + 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); + } + } +} diff --git a/src/commands/transactions/index.ts b/src/commands/transactions/index.ts index 1876c4c2..b41f3329 100644 --- a/src/commands/transactions/index.ts +++ b/src/commands/transactions/index.ts @@ -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); @@ -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 ") .description("Get transaction receipt by hash") @@ -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 ") .description("Appeal a transaction by its hash") + .option("--bond ", "Appeal bond amount (e.g. 500gen, 0.5gen). Auto-calculated if omitted") .option("--rpc ", "RPC URL for the network") .action(async (txId: TransactionHash, options: AppealOptions) => { const appealAction = new AppealAction(); await appealAction.appeal({txId, ...options}); }); + program + .command("appeal-bond ") + .description("Show minimum appeal bond required for a transaction") + .option("--rpc ", "RPC URL for the network") + .action(async (txId: TransactionHash, options: AppealBondOptions) => { + const appealAction = new AppealAction(); + await appealAction.appealBond({txId, ...options}); + }); + return program; -} \ No newline at end of file +} diff --git a/tests/actions/appeal.test.ts b/tests/actions/appeal.test.ts index f0d184ef..f4a8f215 100644 --- a/tests/actions/appeal.test.ts +++ b/tests/actions/appeal.test.ts @@ -3,7 +3,14 @@ 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(); + return { + ...actual, + createClient: vi.fn(), + createAccount: vi.fn(), + }; +}); describe("AppealAction", () => { let appealAction: AppealAction; @@ -11,6 +18,7 @@ describe("AppealAction", () => { appealTransaction: vi.fn(), waitForTransactionReceipt: vi.fn(), initializeConsensusSmartContract: vi.fn(), + getMinAppealBond: vi.fn(), }; const mockPrivateKey = "mocked_private_key"; @@ -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}); @@ -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), + ); }); -}); \ No newline at end of file +}); diff --git a/tests/commands/appeal.test.ts b/tests/commands/appeal.test.ts index db7c51f6..9d5461ac 100644 --- a/tests/commands/appeal.test.ts +++ b/tests/commands/appeal.test.ts @@ -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); @@ -55,4 +63,35 @@ describe("appeal command", () => { program.parse(["node", "test", "appeal", mockTxId, "--invalid-option"]), ).toThrowError("error: unknown option '--invalid-option'"); }); -}); \ No newline at end of file +}); + +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", + }); + }); +});