Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/audit-log-history.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@walletconnect/companion-wallet": minor
---

Add audit log and history operation for companion wallet
65 changes: 65 additions & 0 deletions packages/companion-wallet/src/audit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { appendFileSync, readFileSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { homedir } from "node:os";
import type { AuditEntry, HistoryInput } from "./types.js";

const AUDIT_LOG = join(homedir(), ".config", "wallet", "audit.log");

/** Append an audit entry as a JSONL line. Never throws. */
export function appendAuditEntry(entry: AuditEntry): void {
try {
mkdirSync(dirname(AUDIT_LOG), { recursive: true, mode: 0o700 });
appendFileSync(AUDIT_LOG, JSON.stringify(entry) + "\n", {
mode: 0o600,
});
} catch {
// Audit must never crash the wallet
}
}

/** Read audit log with optional filters. Returns [] if log doesn't exist. */
export function readAuditLog(filters: HistoryInput = {}): AuditEntry[] {
let lines: string[];
try {
lines = readFileSync(AUDIT_LOG, "utf-8").split("\n").filter(Boolean);
} catch {
return [];
}

let entries: AuditEntry[] = [];
for (const line of lines) {
try {
entries.push(JSON.parse(line) as AuditEntry);
} catch {
// Skip malformed lines
}
}

if (filters.operation) {
const op = filters.operation.toLowerCase();
entries = entries.filter((e) => e.operation.toLowerCase() === op);
}

if (filters.chain) {
const ch = filters.chain.toLowerCase();
entries = entries.filter((e) => e.chain?.toLowerCase() === ch);
}

if (filters.account) {
const acc = filters.account.toLowerCase();
entries = entries.filter((e) => e.account?.toLowerCase() === acc);
}

if (filters.since) {
const sinceDate = new Date(filters.since).getTime();
if (!Number.isNaN(sinceDate)) {
entries = entries.filter((e) => new Date(e.timestamp).getTime() >= sinceDate);
}
}

if (filters.last !== undefined && filters.last > 0) {
entries = entries.slice(-filters.last);
}

return entries;
}
114 changes: 109 additions & 5 deletions packages/companion-wallet/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
recordSessionUsage,
SessionError,
} from "./sessions.js";
import { appendAuditEntry, readAuditLog } from "./audit.js";
import {
ExitCode,
type Operation,
Expand All @@ -30,6 +31,7 @@ import {
type RevokeSessionInput,
type GetSessionInput,
type ErrorResponse,
type HistoryInput,
} from "./types.js";

function getVersion(): string {
Expand All @@ -44,23 +46,48 @@ function getVersion(): string {
}
}

let _stdinCache: string | undefined;

async function readStdin(): Promise<string> {
if (process.stdin.isTTY) return "";
if (_stdinCache !== undefined) return _stdinCache;

if (process.stdin.isTTY) {
_stdinCache = "";
return _stdinCache;
}

const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(chunk);
}
return Buffer.concat(chunks).toString("utf-8").trim();
_stdinCache = Buffer.concat(chunks).toString("utf-8").trim();
return _stdinCache;
}

function respond(data: object): void {
let respond = (data: object): void => {
process.stdout.write(JSON.stringify(data) + "\n");
}
};

function respondError(error: string, code: string): void {
let respondError = (error: string, code: string): void => {
const resp: ErrorResponse = { error, code };
process.stdout.write(JSON.stringify(resp) + "\n");
};

/** Fields that must never appear in audit logs */
const REDACTED_FIELDS = new Set(["mnemonic", "signedTransaction", "signature"]);

function sanitizeAuditData(data: unknown): unknown {
if (!data || typeof data !== "object") return data;
const obj = data as Record<string, unknown>;
const sanitized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
if (REDACTED_FIELDS.has(key)) {
sanitized[key] = "[REDACTED]";
} else {
sanitized[key] = value;
}
}
return sanitized;
}

async function parseInput<T>(): Promise<T | null> {
Expand Down Expand Up @@ -91,6 +118,7 @@ async function handleInfo(): Promise<void> {
"get-session",
"fund",
"drain",
"history",
],
chains: SUPPORTED_CHAINS,
};
Expand Down Expand Up @@ -387,6 +415,27 @@ async function handleDrain(): Promise<void> {
}
}

async function handleHistory(): Promise<void> {
try {
const lastRaw = parseCliArg("last");
const filters: HistoryInput = {
operation: parseCliArg("operation"),
chain: parseCliArg("chain"),
account: parseCliArg("account"),
last: lastRaw ? Number(lastRaw) : undefined,
since: parseCliArg("since"),
};
const entries = readAuditLog(filters);
respond({ entries });
} catch (err) {
respondError(
err instanceof Error ? err.message : "History read failed",
"HISTORY_ERROR",
);
process.exit(ExitCode.ERROR);
}
}

function validateSessionOrExit(
sessionId: string,
operation: string,
Expand Down Expand Up @@ -421,6 +470,7 @@ const HANDLERS: Record<Operation, () => Promise<void>> = {
"get-session": handleGetSession,
fund: handleFund,
drain: handleDrain,
history: handleHistory,
};

async function main(): Promise<void> {
Expand All @@ -434,6 +484,60 @@ async function main(): Promise<void> {
process.exit(ExitCode.UNSUPPORTED);
}

// Eagerly read stdin so it's cached for both audit and handler
const rawStdin = await readStdin();

// Audit context — captured during execution, written on exit
const shouldAudit = operation !== "history";
let auditOutput: unknown = undefined;
let auditError: string | undefined = undefined;

if (shouldAudit) {
const origRespond = respond;
const origRespondError = respondError;

respond = (data: object): void => {
auditOutput = data;
origRespond(data);
};

respondError = (error: string, code: string): void => {
auditError = error;
auditOutput = { error, code };
origRespondError(error, code);
};

process.on("exit", () => {
let parsedInput: unknown = undefined;
try {
parsedInput = rawStdin ? JSON.parse(rawStdin) : undefined;
} catch {
parsedInput = rawStdin || undefined;
}

const inputObj =
parsedInput && typeof parsedInput === "object" && parsedInput !== null
? (parsedInput as Record<string, unknown>)
: undefined;

// Sanitize sensitive fields from audit entries
const sanitizedInput = sanitizeAuditData(parsedInput);
const sanitizedOutput = sanitizeAuditData(auditOutput);

appendAuditEntry({
timestamp: new Date().toISOString(),
operation,
input: sanitizedInput,
output: sanitizedOutput,
account: parseCliArg("account") ?? (typeof inputObj?.account === "string" ? inputObj.account : undefined),
chain: parseCliArg("chain") ?? (typeof inputObj?.chain === "string" ? inputObj.chain : undefined),
sessionId: typeof inputObj?.sessionId === "string" ? inputObj.sessionId : undefined,
success: auditError === undefined,
error: auditError,
});
});
}

try {
await HANDLERS[operation]();
} catch (err) {
Expand Down
3 changes: 3 additions & 0 deletions packages/companion-wallet/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export {
recordSessionUsage,
SessionError,
} from "./sessions.js";
export { appendAuditEntry, readAuditLog } from "./audit.js";
export { ExitCode } from "./types.js";
export type {
Operation,
Expand All @@ -43,4 +44,6 @@ export type {
BalanceResponse,
WalletFile,
ErrorResponse,
AuditEntry,
HistoryInput,
} from "./types.js";
25 changes: 24 additions & 1 deletion packages/companion-wallet/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ export type Operation =
| "get-session"
| "balance"
| "fund"
| "drain";
| "drain"
| "history";

/** Info response */
export interface InfoResponse {
Expand Down Expand Up @@ -181,3 +182,25 @@ export interface WalletFile {
address: string;
mnemonic: string;
}

/** Audit log entry */
export interface AuditEntry {
timestamp: string;
operation: string;
input: unknown;
output: unknown;
account?: string;
chain?: string;
sessionId?: string;
success: boolean;
error?: string;
}

/** History query input (CLI flags) */
export interface HistoryInput {
operation?: string;
chain?: string;
account?: string;
last?: number;
since?: string;
}
Loading