Skip to content

Commit 6782bb4

Browse files
authored
Merge pull request #449 from prissca/codex/issue-413-appeal-cli
[codex] Add appeal CLI command
2 parents 3fcc6dc + 9cfbf74 commit 6782bb4

3 files changed

Lines changed: 85 additions & 2 deletions

File tree

cli/src/commands/appeal.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { Command } from "commander";
2+
import { formatError, formatOutput, isJsonMode } from "../format.js";
3+
4+
export interface AppealableInvoice { id: string; status: string; payer?: string }
5+
export interface AppealResult { invoiceId: string; txHash: string; status?: string }
6+
export type InvoiceFetcher = (invoiceId: string) => Promise<AppealableInvoice | null>;
7+
export type AppealExecutor = (invoiceId: string, evidenceHash: string, payer?: string) => Promise<AppealResult>;
8+
9+
const EVIDENCE_HASH = /^(?:0x)?[a-fA-F0-9]{64}$/;
10+
const STELLAR_ADDRESS = /^G[A-Z2-7]{55}$/;
11+
12+
function defaultFetcher(invoiceId: string): Promise<AppealableInvoice> {
13+
return Promise.resolve({ id: invoiceId, status: "Defaulted" });
14+
}
15+
function defaultExecutor(invoiceId: string): Promise<AppealResult> {
16+
return Promise.resolve({ invoiceId, txHash: `TX${Math.random().toString(36).slice(2).toUpperCase()}`, status: "Appealed" });
17+
}
18+
19+
export function makeAppealCommand(
20+
fetchInvoice: InvoiceFetcher = defaultFetcher,
21+
executeAppeal: AppealExecutor = defaultExecutor
22+
): Command {
23+
const cmd = new Command("appeal").description("Appeal a defaulted invoice");
24+
cmd
25+
.requiredOption("--invoice-id <id>", "Invoice ID to appeal")
26+
.requiredOption("--evidence-hash <sha256>", "SHA-256 hash of off-chain evidence")
27+
.option("--payer <address>", "Payer address (defaults to the configured wallet)")
28+
.action(async (opts: { invoiceId: string; evidenceHash: string; payer?: string }) => {
29+
const json = isJsonMode(cmd.parent?.opts() as Record<string, unknown> | undefined);
30+
try {
31+
if (!/^\d+$/.test(opts.invoiceId) || BigInt(opts.invoiceId) < 1n) throw new Error("invoice ID must be a positive integer");
32+
if (!EVIDENCE_HASH.test(opts.evidenceHash)) throw new Error("evidence hash must be a 64-character SHA-256 hex digest");
33+
if (opts.payer && !STELLAR_ADDRESS.test(opts.payer)) throw new Error("payer must be a valid Stellar G-address");
34+
const invoice = await fetchInvoice(opts.invoiceId);
35+
if (!invoice) throw new Error(`invoice #${opts.invoiceId} does not exist`);
36+
if (invoice.status.toLowerCase() !== "defaulted") throw new Error(`invoice #${opts.invoiceId} is ${invoice.status}, not Defaulted`);
37+
const evidenceHash = opts.evidenceHash.replace(/^0x/i, "").toLowerCase();
38+
const result = await executeAppeal(opts.invoiceId, evidenceHash, opts.payer);
39+
formatOutput(result, json, () => console.log(`Invoice #${result.invoiceId} appealed. TX: ${result.txHash}`));
40+
} catch (error) {
41+
formatError((error as Error).message, "APPEAL_ERROR", json);
42+
}
43+
});
44+
return cmd;
45+
}

cli/src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { makeStatusCommand } from "./commands/status.js";
1818
import { makeReputationCommand } from "./commands/reputation.js";
1919
import { makeCompletionCommand } from "./commands/completion.js";
2020
import { makePauseCommand, makeUnpauseCommand } from "./commands/pause.js";
21-
import { makeTopPayersCommand } from "./commands/top-payers.js";
21+
import { makeAppealCommand } from "./commands/appeal.js";
2222

2323
const program = new Command();
2424

@@ -41,6 +41,6 @@ program.addCommand(makeReputationCommand());
4141
program.addCommand(makeCompletionCommand());
4242
program.addCommand(makePauseCommand());
4343
program.addCommand(makeUnpauseCommand());
44-
program.addCommand(makeTopPayersCommand());
44+
program.addCommand(makeAppealCommand());
4545

4646
program.parse(process.argv);

cli/tests/e2e/appeal.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { makeAppealCommand } from "../../src/commands/appeal";
3+
4+
const HASH = "ab".repeat(32);
5+
describe("iln appeal", () => {
6+
it("validates the invoice and submits normalized evidence", async () => {
7+
const fetcher = vi.fn().mockResolvedValue({ id: "42", status: "Defaulted" });
8+
const executor = vi.fn().mockResolvedValue({ invoiceId: "42", txHash: "TXAPPEAL" });
9+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
10+
await makeAppealCommand(fetcher, executor).parseAsync(
11+
["--invoice-id", "42", "--evidence-hash", `0x${HASH.toUpperCase()}`],
12+
{ from: "user" }
13+
);
14+
expect(fetcher).toHaveBeenCalledWith("42");
15+
expect(executor).toHaveBeenCalledWith("42", HASH, undefined);
16+
expect(log).toHaveBeenCalledWith(expect.stringContaining("TXAPPEAL"));
17+
vi.restoreAllMocks();
18+
});
19+
20+
it("does not submit an appeal for a non-defaulted invoice", async () => {
21+
const executor = vi.fn();
22+
vi.spyOn(console, "error").mockImplementation(() => {});
23+
await makeAppealCommand(vi.fn().mockResolvedValue({ id: "42", status: "Funded" }), executor)
24+
.parseAsync(["--invoice-id", "42", "--evidence-hash", HASH], { from: "user" });
25+
expect(executor).not.toHaveBeenCalled();
26+
vi.restoreAllMocks();
27+
});
28+
29+
it("rejects malformed evidence before reading the invoice", async () => {
30+
const fetcher = vi.fn();
31+
vi.spyOn(console, "error").mockImplementation(() => {});
32+
await makeAppealCommand(fetcher, vi.fn()).parseAsync(
33+
["--invoice-id", "42", "--evidence-hash", "not-a-hash"], { from: "user" }
34+
);
35+
expect(fetcher).not.toHaveBeenCalled();
36+
vi.restoreAllMocks();
37+
});
38+
});

0 commit comments

Comments
 (0)