From 4694804a403d57a9dcb49e50975d89cb32bb14e6 Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Fri, 12 Jun 2026 08:24:05 +0800 Subject: [PATCH] feat: add history command and /wallet/history endpoint - Add 'history' CLI command with --limit, --offset, --verbose, --json options - Add GET /wallet/history daemon endpoint - Implement getHistory() in coco-client using coco.history.getPaginatedHistory() - Add getHistory stub (returns []) to cocod-client interface --- src/cli.ts | 102 ++++++++++++++++++++++++++++++ src/daemon/http/index.ts | 27 ++++++++ src/daemon/wallet/coco-client.ts | 5 ++ src/daemon/wallet/cocod-client.ts | 5 ++ 4 files changed, 139 insertions(+) diff --git a/src/cli.ts b/src/cli.ts index 26e7411..615acb9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1113,6 +1113,108 @@ npubsCmd ); }); +// History - show transaction history +program + .command("history") + .description("Show wallet transaction history") + .option("-n, --limit ", "Number of entries to show", "50") + .option("--offset ", "Number of entries to skip", "0") + .option("-v, --verbose", "Show full details including encoded Cashu tokens") + .option("--json", "Output raw JSON with token objects (no encoding)") + .action(async (options: { limit: string; offset: string; verbose: boolean; json: boolean }) => { + await ensureDaemonRunning(); + + const limit = Math.min(parseInt(options.limit, 10) || 50, 1000); + const offset = parseInt(options.offset, 10) || 0; + + const result = await callDaemon( + `/wallet/history?offset=${offset}&limit=${limit}`, + ); + + if (result.error) { + console.log(result.error); + process.exit(1); + } + + const data = result.output as + | { entries?: Record[]; offset?: number; limit?: number } + | undefined; + const entries = data?.entries || []; + + if (entries.length === 0) { + console.log("No transaction history yet."); + return; + } + + if (options.json) { + // Raw JSON output with token as JSON object + const jsonOutput = entries.map((e: Record) => { + const entry = { ...e }; + delete entry.encodedToken; + return entry; + }); + console.log(JSON.stringify(jsonOutput, null, 2)); + return; + } + + if (options.verbose) { + // Verbose: JSON-like with encoded token + for (const entry of entries) { + const e = entry as Record; + const display: Record = {}; + for (const key of Object.keys(e).sort()) { + display[key] = e[key]; + } + // Replace raw token with encoded one if present + if (e.encodedToken && e.token) { + display.token = e.encodedToken; + delete display.encodedToken; + } + console.log(JSON.stringify(display, null, 2)); + } + return; + } + + // Default: table format + const idCol = "ID"; + const timeCol = "Date/Time"; + const typeCol = "Type"; + const mintCol = "Mint"; + const amtCol = "Amount"; + + const rows = entries.map((entry: Record) => { + const id = String(entry.id ?? ""); + const time = new Date(Number(entry.createdAt)).toISOString().replace("T", " ").slice(0, 19); + const type = String(entry.type ?? "").toUpperCase(); + const mint = String(entry.mintUrl ?? ""); + const unit = String(entry.unit ?? "sat"); + const amount = `${entry.amount} ${unit}`; + return { id, time, type, mint, amount }; + }); + + const widths = { + id: Math.max(idCol.length, ...rows.map((r) => r.id.length)), + time: Math.max(timeCol.length, ...rows.map((r) => r.time.length)), + type: Math.max(typeCol.length, ...rows.map((r) => r.type.length)), + mint: Math.max(mintCol.length, ...rows.map((r) => r.mint.length)), + amount: Math.max(amtCol.length, ...rows.map((r) => r.amount.length)), + }; + + const pad = (s: string, w: number) => s.padEnd(w); + const sep = Object.values(widths).map((w) => "-".repeat(w)).join(" | "); + + console.log( + `${pad(idCol, widths.id)} | ${pad(timeCol, widths.time)} | ${pad(typeCol, widths.type)} | ${pad(mintCol, widths.mint)} | ${pad(amtCol, widths.amount)}`, + ); + console.log(sep); + + for (const row of rows) { + console.log( + `${pad(row.id, widths.id)} | ${pad(row.time, widths.time)} | ${pad(row.type, widths.type)} | ${pad(row.mint, widths.mint)} | ${pad(row.amount, widths.amount)}`, + ); + } + }); + // Monitor - interactive TUI program .command("monitor") diff --git a/src/daemon/http/index.ts b/src/daemon/http/index.ts index 83337a1..46dc119 100644 --- a/src/daemon/http/index.ts +++ b/src/daemon/http/index.ts @@ -8,6 +8,8 @@ import { } from "@routstr/sdk"; import type { UsageTrackingDriver, SdkLogger } from "@routstr/sdk"; import type { RequestResponseLogSink } from "../request-response-log-sink"; +import { getEncodedToken } from "@cashu/coco-core"; +import type { HistoryEntry } from "@cashu/coco-core"; import { logger } from "../../utils/logger"; import { loadDaemonConfig, saveDaemonConfig } from "../config-store"; import { @@ -437,6 +439,31 @@ export function createDaemonRequestHandler(deps: { return; } + if (req.method === "GET" && url.pathname === "/wallet/history") { + await respond(res, async () => { + const offsetParam = url.searchParams.get("offset"); + const limitParam = url.searchParams.get("limit"); + const offset = offsetParam ? parseInt(offsetParam, 10) || 0 : 0; + const limit = limitParam ? parseInt(limitParam, 10) || 50 : 50; + const entries = await deps.walletClient.getHistory(offset, limit); + + // Encode tokens for send/receive entries + const encoded = entries.map((entry: HistoryEntry) => { + const base = { ...entry } as Record; + if ( + (entry.type === "send" || entry.type === "receive") && + entry.token + ) { + base.encodedToken = getEncodedToken(entry.token); + } + return base; + }); + + return { output: { entries: encoded, offset, limit } }; + }); + return; + } + // ── NWC endpoints ───────────────────────────────────────────── if (req.method === "GET" && url.pathname === "/nwc/status") { diff --git a/src/daemon/wallet/coco-client.ts b/src/daemon/wallet/coco-client.ts index c0e63b9..f4c10c5 100644 --- a/src/daemon/wallet/coco-client.ts +++ b/src/daemon/wallet/coco-client.ts @@ -1,4 +1,5 @@ import { initializeCoco, getEncodedToken } from "@cashu/coco-core"; +import type { HistoryEntry } from "@cashu/coco-core"; import { SqliteRepositories } from "@cashu/coco-sqlite-bun"; import { Database } from "bun:sqlite"; import { @@ -433,5 +434,9 @@ export async function createCocoClient( } } }, + + async getHistory(offset?: number, limit?: number): Promise { + return coco.history.getPaginatedHistory(offset, limit); + }, }; } diff --git a/src/daemon/wallet/cocod-client.ts b/src/daemon/wallet/cocod-client.ts index e4cc782..5256d99 100644 --- a/src/daemon/wallet/cocod-client.ts +++ b/src/daemon/wallet/cocod-client.ts @@ -2,6 +2,7 @@ import { existsSync } from "fs"; import { createHash } from "crypto"; import { logger } from "../../utils/logger"; import { withCrossProcessLock } from "../../utils/process-lock"; +import type { HistoryEntry } from "@cashu/coco-core"; const DEFAULT_CONFIG_DIR = process.env.COCOD_DIR || `${process.env.HOME || process.env.USERPROFILE || ""}/.cocod`; @@ -58,6 +59,7 @@ export interface CocodClient { getMintInfo(url: string): Promise; /** Release resources held by in-process wallet implementations. */ dispose?(): Promise; + getHistory(offset?: number, limit?: number): Promise; } export function resolveCocodExecutable(cocodPath?: string | null): string { @@ -355,5 +357,8 @@ export function createCocodClient( async getMintInfo(url: string): Promise { return post("/mints/info", { url }); }, + async getHistory(_offset?: number, _limit?: number): Promise { + return []; + }, }; }