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
This commit is contained in:
redshift
2026-07-16 01:08:47 +08:00
parent aaafe91a4a
commit 4694804a40
4 changed files with 139 additions and 0 deletions
+102
View File
@@ -1113,6 +1113,108 @@ npubsCmd
);
});
// History - show transaction history
program
.command("history")
.description("Show wallet transaction history")
.option("-n, --limit <number>", "Number of entries to show", "50")
.option("--offset <number>", "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<string, unknown>[]; 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<string, unknown>) => {
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<string, unknown>;
const display: Record<string, unknown> = {};
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<string, unknown>) => {
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")
+27
View File
@@ -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<string, unknown>;
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") {
+5
View File
@@ -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<HistoryEntry[]> {
return coco.history.getPaginatedHistory(offset, limit);
},
};
}
+5
View File
@@ -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<unknown>;
/** Release resources held by in-process wallet implementations. */
dispose?(): Promise<void>;
getHistory(offset?: number, limit?: number): Promise<HistoryEntry[]>;
}
export function resolveCocodExecutable(cocodPath?: string | null): string {
@@ -355,5 +357,8 @@ export function createCocodClient(
async getMintInfo(url: string): Promise<unknown> {
return post<unknown>("/mints/info", { url });
},
async getHistory(_offset?: number, _limit?: number): Promise<HistoryEntry[]> {
return [];
},
};
}