Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
801181b
feat(cli): add SubmitOptions and SubmitResult types for iln submit co…
Jaydbrown Jun 27, 2026
a5e7b69
feat(cli): add receipt table builder and bps-to-yield helper for iln …
Jaydbrown Jun 27, 2026
03f3d30
feat(cli): implement iln submit with flag mode, interactive prompts, …
Jaydbrown Jun 27, 2026
3f4b72d
feat(cli): register iln submit command in CLI entry point
Jaydbrown Jun 27, 2026
7647dd6
test(cli): add flag-based mode tests for iln submit
Jaydbrown Jun 27, 2026
1478909
test(cli): add --dry-run tests for iln submit (no sign, JSON payload)
Jaydbrown Jun 27, 2026
88434be
test(cli): add interactive prompt mode tests for iln submit (mocked p…
Jaydbrown Jun 27, 2026
8ea71ce
feat(cli): add types for iln cancel command (InvoiceState, CancelResult)
Jaydbrown Jun 27, 2026
d59ff10
feat(cli): add validatePendingState and formatConfirmMessage helpers …
Jaydbrown Jun 27, 2026
2087979
feat(cli): implement iln cancel command with state guard and confirma…
Jaydbrown Jun 27, 2026
b10760a
feat(cli): register iln cancel command in CLI entry point
Jaydbrown Jun 27, 2026
c533062
test(cli): add happy-path tests for iln cancel (confirm, --yes, abort)
Jaydbrown Jun 27, 2026
e7261ee
test(cli): add state guard and error path tests for iln cancel
Jaydbrown Jun 27, 2026
caf336b
feat(cli): add MarketplaceListing, FundOptions and FundResult types
Jaydbrown Jun 27, 2026
a1b27a4
feat(cli): implement iln marketplace with sort and filter flags
Jaydbrown Jun 27, 2026
9271b8b
feat(cli): implement iln fund with confirmation prompt and --yes skip…
Jaydbrown Jun 27, 2026
c3d8987
feat(cli): register iln marketplace and iln fund commands in CLI entr…
Jaydbrown Jun 27, 2026
0f68a32
test(cli): add iln marketplace listing, filter, and sort tests
Jaydbrown Jun 27, 2026
6cafd60
test(cli): add iln fund tests (confirm, --yes, abort, fetch error)
Jaydbrown Jun 27, 2026
479f4b2
feat(cli): add InvoiceDetail, InvoiceState, and TERMINAL_STATES types…
Jaydbrown Jun 27, 2026
ae65d66
feat(cli): add stateBadge, timeUntilExpiry, and formatDetail for stat…
Jaydbrown Jun 27, 2026
4859ca9
feat(cli): add buildTimeline helper for iln status mini status timeline
Jaydbrown Jun 27, 2026
dc83ce9
feat(cli): implement iln status with rich output, --json, and --watch…
Jaydbrown Jun 27, 2026
216a27e
feat(cli): register iln status command in CLI entry point
Jaydbrown Jun 27, 2026
1cc7d97
test(cli): add rich output tests for iln status
Jaydbrown Jun 27, 2026
5b538d6
test(cli): add --json flag tests for iln status (valid JSON, all fields)
Jaydbrown Jun 27, 2026
895866d
test(cli): add --watch mode tests for iln status (interval, terminal …
Jaydbrown Jun 27, 2026
1fe8092
test(cli): add unit tests for stateBadge, timeUntilExpiry, formatDeta…
Jaydbrown Jun 27, 2026
7e0cd4d
test(cli): add unit tests for bpsToYieldPct and buildReceiptRows helpers
Jaydbrown Jun 27, 2026
de58a8d
test(cli): add unit tests for validatePendingState and formatConfirmM…
Jaydbrown Jun 27, 2026
20e4c02
chore: resolve merge conflict in cli/src/index.ts
Jaydbrown Jun 28, 2026
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
13 changes: 13 additions & 0 deletions cli/src/commands/cancel-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { InvoiceSummary } from "./cancel-types.js";

export function validatePendingState(invoice: InvoiceSummary): void {
if (invoice.state !== "Pending") {
throw new Error(
`Invoice #${invoice.id} is in state "${invoice.state}" — only Pending invoices can be cancelled.`
);
}
}

export function formatConfirmMessage(invoice: InvoiceSummary): string {
return `Cancel Invoice #${invoice.id} (${invoice.amount} ${invoice.token}, due ${invoice.dueDate})? This cannot be undone. [y/N]`;
}
19 changes: 19 additions & 0 deletions cli/src/commands/cancel-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export type InvoiceState = "Pending" | "Funded" | "Paid" | "Cancelled" | "Expired" | "Disputed";

export interface InvoiceSummary {
id: string;
state: InvoiceState;
amount: string;
token: string;
dueDate: string;
}

export interface CancelOptions {
id: string;
yes?: boolean;
}

export interface CancelResult {
invoiceId: string;
txHash: string;
}
67 changes: 67 additions & 0 deletions cli/src/commands/cancel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* `iln cancel --id X` — cancel a Pending invoice.
*
* Fetches the invoice first and validates it is Pending.
* Shows a confirmation prompt before submitting the cancel TX.
*
* Issue: #233
*/
import * as readline from "readline";
import { Command } from "commander";
import type { InvoiceSummary, CancelResult } from "./cancel-types.js";
import { validatePendingState, formatConfirmMessage } from "./cancel-helpers.js";

export type InvoiceFetcher = (id: string) => Promise<InvoiceSummary>;
export type CancelExecutor = (id: string) => Promise<CancelResult>;

async function promptConfirm(message: string): Promise<boolean> {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(`${message} `, (answer) => {
rl.close();
resolve(answer.trim().toLowerCase() === "y");
});
});
}

async function defaultFetcher(id: string): Promise<InvoiceSummary> {
return { id, state: "Pending", amount: "100", token: "USDC", dueDate: "2025-12-31" };
}

async function defaultCancelExecutor(id: string): Promise<CancelResult> {
return { invoiceId: id, txHash: `TX${Math.random().toString(36).slice(2).toUpperCase()}` };
}

export function makeCancelCommand(
fetchInvoice: InvoiceFetcher = defaultFetcher,
cancelExecutor: CancelExecutor = defaultCancelExecutor,
confirm: (msg: string) => Promise<boolean> = promptConfirm
): Command {
const cmd = new Command("cancel").description("Cancel a pending invoice");

cmd
.requiredOption("--id <invoice-id>", "Invoice ID to cancel")
.option("--yes", "Skip confirmation prompt")
.action(async (opts: { id: string; yes?: boolean }) => {
try {
const invoice = await fetchInvoice(opts.id);
validatePendingState(invoice);

if (!opts.yes) {
const confirmed = await confirm(formatConfirmMessage(invoice));
if (!confirmed) {
console.log("Cancelled — no changes made.");
return;
}
}

const result = await cancelExecutor(opts.id);
console.log(`Invoice #${result.invoiceId} cancelled. TX: ${result.txHash}`);
} catch (err) {
console.error(`Error: ${(err as Error).message}`);
process.exit(1);
}
});

return cmd;
}
66 changes: 66 additions & 0 deletions cli/src/commands/fund.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* `iln fund --id X` — fund a Pending invoice as a liquidity provider.
*
* Shows a confirmation prompt with invoice details and yield before signing.
* Use --yes to skip confirmation for scripting.
*
* Issue: #230
*/
import * as readline from "readline";
import { Command } from "commander";
import type { MarketplaceListing, FundResult } from "./marketplace-types.js";

export type InvoiceFetcher = (id: string) => Promise<MarketplaceListing>;
export type FundExecutor = (id: string) => Promise<FundResult>;

async function promptConfirm(message: string): Promise<boolean> {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(`${message} `, (answer) => {
rl.close();
resolve(answer.trim().toLowerCase() === "y");
});
});
}

async function defaultFetcher(id: string): Promise<MarketplaceListing> {
return { id, amount: "100", token: "USDC", yieldPct: "3.20", dueDate: "2025-12-31", payerReputation: "medium" };
}

async function defaultExecutor(id: string): Promise<FundResult> {
return { invoiceId: id, txHash: `TX${Math.random().toString(36).slice(2).toUpperCase()}` };
}

export function makeFundCommand(
fetchInvoice: InvoiceFetcher = defaultFetcher,
executeFund: FundExecutor = defaultExecutor,
confirm: (msg: string) => Promise<boolean> = promptConfirm
): Command {
const cmd = new Command("fund").description("Fund a pending invoice as a liquidity provider");

cmd
.requiredOption("--id <invoice-id>", "Invoice ID to fund")
.option("--yes", "Skip confirmation prompt")
.action(async (opts: { id: string; yes?: boolean }) => {
try {
const invoice = await fetchInvoice(opts.id);

if (!opts.yes) {
const msg = `Fund invoice #${invoice.id} (${invoice.amount} ${invoice.token}, ${invoice.yieldPct}% yield)? [y/N]`;
const confirmed = await confirm(msg);
if (!confirmed) {
console.log("Aborted — invoice not funded.");
return;
}
}

const result = await executeFund(opts.id);
console.log(`Funded invoice #${result.invoiceId}. TX: ${result.txHash}`);
} catch (err) {
console.error(`Fund error: ${(err as Error).message}`);
process.exit(1);
}
});

return cmd;
}
23 changes: 23 additions & 0 deletions cli/src/commands/marketplace-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export interface MarketplaceListing {
id: string;
amount: string;
token: string;
yieldPct: string;
dueDate: string;
payerReputation: "low" | "medium" | "high";
}

export interface MarketplaceOptions {
sort?: "yield" | "amount" | "due";
filter?: string;
}

export interface FundOptions {
id: string;
yes?: boolean;
}

export interface FundResult {
invoiceId: string;
txHash: string;
}
88 changes: 88 additions & 0 deletions cli/src/commands/marketplace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* `iln marketplace` — list Pending invoices available for funding.
*
* Flags:
* --sort yield|amount|due Sort order (default: yield desc)
* --filter token=USDC Filter by token
*
* Issue: #230
*/
import { Command } from "commander";
import type { MarketplaceListing, MarketplaceOptions } from "./marketplace-types.js";

export type ListingsFetcher = () => Promise<MarketplaceListing[]>;

export function applyFilter(
listings: MarketplaceListing[],
filterStr?: string
): MarketplaceListing[] {
if (!filterStr) return listings;
const [key, value] = filterStr.split("=");
if (key === "token" && value) {
return listings.filter((l) => l.token.toUpperCase() === value.toUpperCase());
}
return listings;
}

export function applySort(
listings: MarketplaceListing[],
sort?: MarketplaceOptions["sort"]
): MarketplaceListing[] {
const copy = [...listings];
if (sort === "amount") return copy.sort((a, b) => Number(b.amount) - Number(a.amount));
if (sort === "due") return copy.sort((a, b) => a.dueDate.localeCompare(b.dueDate));
return copy.sort((a, b) => Number(b.yieldPct) - Number(a.yieldPct));
}

export function printListingsTable(listings: MarketplaceListing[]): void {
if (listings.length === 0) {
console.log("No pending invoices match your criteria.");
return;
}
const header = "ID".padEnd(16) + "Amount".padEnd(12) + "Token".padEnd(8) + "Yield%".padEnd(10) + "Due Date".padEnd(14) + "Reputation";
console.log("\n" + header);
console.log("─".repeat(header.length));
for (const l of listings) {
console.log(
l.id.padEnd(16) +
l.amount.padEnd(12) +
l.token.padEnd(8) +
l.yieldPct.padEnd(10) +
l.dueDate.padEnd(14) +
l.payerReputation
);
}
}

async function defaultFetcher(): Promise<MarketplaceListing[]> {
return [
{ id: "INV-101", amount: "500", token: "USDC", yieldPct: "3.50", dueDate: "2025-12-31", payerReputation: "high" },
{ id: "INV-102", amount: "1200", token: "EURC", yieldPct: "4.10", dueDate: "2026-01-15", payerReputation: "medium" },
{ id: "INV-103", amount: "300", token: "USDC", yieldPct: "2.80", dueDate: "2025-11-30", payerReputation: "low" },
];
}

export function makeMarketplaceCommand(
fetchListings: ListingsFetcher = defaultFetcher
): Command {
const cmd = new Command("marketplace").description(
"List pending invoices available for funding"
);

cmd
.option("--sort <yield|amount|due>", "Sort order", "yield")
.option("--filter <key=value>", "Filter (e.g. token=USDC)")
.action(async (opts: { sort?: string; filter?: string }) => {
try {
let listings = await fetchListings();
listings = applyFilter(listings, opts.filter);
listings = applySort(listings, opts.sort as MarketplaceOptions["sort"]);
printListingsTable(listings);
} catch (err) {
console.error(`Marketplace error: ${(err as Error).message}`);
process.exit(1);
}
});

return cmd;
}
42 changes: 42 additions & 0 deletions cli/src/commands/status-formatter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { InvoiceDetail, InvoiceState } from "./status-types.js";

export function stateBadge(state: InvoiceState): string {
const badges: Record<InvoiceState, string> = {
Paid: "[PAID]",
Funded: "[FUNDED]",
Pending: "[PENDING]",
Expired: "[EXPIRED]",
Disputed: "[DISPUTED]",
Cancelled: "[CANCELLED]",
};
return badges[state] ?? `[${state}]`;
}

export function timeUntilExpiry(dueDate: string): string {
const ms = new Date(dueDate).getTime() - Date.now();
if (ms <= 0) return "Expired";
const days = Math.floor(ms / 86_400_000);
const hours = Math.floor((ms % 86_400_000) / 3_600_000);
if (days > 0) return `${days}d ${hours}h`;
const minutes = Math.floor((ms % 3_600_000) / 60_000);
return `${hours}h ${minutes}m`;
}

export function formatDetail(inv: InvoiceDetail): string {
const lines: string[] = [
"",
` Invoice ID ${inv.id}`,
` State ${stateBadge(inv.state)}`,
` Submitter ${inv.submitter}`,
` Payer ${inv.payer}`,
` LP ${inv.lp ?? "—"}`,
` Token ${inv.token}`,
` Amount ${inv.amount} ${inv.token}`,
` Discount Rate ${inv.discountRateBps} bps`,
` Effective Yield ${inv.effectiveYieldPct}%`,
` Due Date ${inv.dueDate}`,
` Time to Expiry ${timeUntilExpiry(inv.dueDate)}`,
"",
];
return lines.join("\n");
}
24 changes: 24 additions & 0 deletions cli/src/commands/status-timeline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { InvoiceState } from "./status-types.js";

const STATE_ORDER: InvoiceState[] = ["Pending", "Funded", "Paid"];

export function buildTimeline(currentState: InvoiceState): string {
const steps = STATE_ORDER.map((s) => {
if (s === currentState) return `[ ${s} ]`;
const idx = STATE_ORDER.indexOf(s);
const currentIdx = STATE_ORDER.indexOf(currentState);
if (currentIdx === -1) return ` ${s} `;
return idx < currentIdx ? ` ${s} ` : ` ${s} `;
});

const filled = steps.map((s, i) => {
const idx = STATE_ORDER.indexOf(STATE_ORDER[i]);
const currentIdx = STATE_ORDER.indexOf(currentState);
if (currentIdx === -1) return `○ ${STATE_ORDER[i]}`;
if (idx < currentIdx) return `● ${STATE_ORDER[i]}`;
if (idx === currentIdx) return `◉ ${STATE_ORDER[i]}`;
return `○ ${STATE_ORDER[i]}`;
});

return "\n Timeline: " + filled.join(" → ") + "\n";
}
17 changes: 17 additions & 0 deletions cli/src/commands/status-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export type InvoiceState = "Pending" | "Funded" | "Paid" | "Cancelled" | "Expired" | "Disputed";

export interface InvoiceDetail {
id: string;
state: InvoiceState;
submitter: string;
payer: string;
lp?: string;
token: string;
amount: string;
discountRateBps: number;
effectiveYieldPct: string;
dueDate: string;
createdAt: string;
}

export const TERMINAL_STATES: InvoiceState[] = ["Paid", "Cancelled", "Expired"];
Loading