diff --git a/.fdignore b/.fdignore new file mode 100644 index 0000000..b67ca56 --- /dev/null +++ b/.fdignore @@ -0,0 +1,3 @@ +node_modules +.claude +.archon diff --git a/.gitignore b/.gitignore index 8c68c1a..beced03 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ node_modules/ dist/ bun.lockb +.claude +.archon diff --git a/package.json b/package.json index ae4bde7..9034d05 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "type": "module", "scripts": { "start": "bun run src/index.ts", - "type-check": "bunx tsc --noEmit" + "type-check": "bunx tsc --noEmit", + "test": "bun test" }, "dependencies": { "@nostr-dev-kit/ndk": "latest" diff --git a/src/csv.ts b/src/csv.ts index 05d981c..ecbc660 100644 --- a/src/csv.ts +++ b/src/csv.ts @@ -1,6 +1,6 @@ import type { TransactionRecord } from "./types.js"; -function escapeField(value: string): string { +export function escapeField(value: string): string { if (value.includes(",") || value.includes('"') || value.includes("\n")) { return `"${value.replace(/"/g, '""')}"`; } diff --git a/src/events.ts b/src/events.ts index d94176c..2b0f7a0 100644 --- a/src/events.ts +++ b/src/events.ts @@ -171,14 +171,20 @@ export async function fetchNutzapEvents( try { const proofTags = event.getMatchingTags("proof"); let totalAmount = 0; + let skippedProofs = 0; for (const tag of proofTags) { try { const proof = JSON.parse(tag[1]); totalAmount += proof.amount ?? 0; } catch { - // skip malformed proof tag + skippedProofs++; } } + if (skippedProofs > 0) { + console.error( + `Warning: skipped ${skippedProofs} malformed proof tag(s) in event ${event.id}` + ); + } const uTag = event.getMatchingTags("u"); const mint = uTag.length > 0 ? uTag[0][1] : ""; diff --git a/src/index.ts b/src/index.ts index e2fa41f..8ea95d2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,8 +35,8 @@ async function main() { ? args[mintsIdx + 1].split(",").filter(Boolean) : []; - // Initialize NDK - const { ndk, signer, user } = await initNDK(secretKey, extraMints); + // Initialize NDK (don't pass mints as relays — they use different protocols) + const { ndk, signer, user } = await initNDK(secretKey); // Fetch wallet data first (need mints for context) const walletData = await fetchWalletData(ndk, user, signer); diff --git a/src/ndk.ts b/src/ndk.ts index ff6a8bf..4e7229b 100644 --- a/src/ndk.ts +++ b/src/ndk.ts @@ -4,23 +4,30 @@ const DEFAULT_RELAYS = [ "wss://relay.damus.io", "wss://relay.primal.net", "wss://nos.lol", - "wss://relay.nostr.band", + "wss://purplepag.es", + "wss://indexer.coracle.social", ]; export async function initNDK(secretKey: string, extraRelays: string[] = []) { - const signer = new NDKPrivateKeySigner(secretKey); - const relays = [...new Set([...DEFAULT_RELAYS, ...extraRelays])]; + let signer: NDKPrivateKeySigner; + try { + signer = new NDKPrivateKeySigner(secretKey); + } catch (err) { + throw new Error( + `Invalid NOSTR_SECRET_KEY: must be 64-char hex or nsec format. ${err instanceof Error ? err.message : err}` + ); + } - const ndk = new NDK({ - explicitRelayUrls: relays, - signer, - }); + const relays = [...new Set([...DEFAULT_RELAYS, ...extraRelays])]; + const ndk = new NDK({ explicitRelayUrls: relays, signer }); console.error(`Connecting to ${relays.length} relays...`); - await ndk.connect(); + const timeout = new Promise((_, reject) => + setTimeout(() => reject(new Error("Relay connection timed out after 30s")), 30000) + ); + await Promise.race([ndk.connect(), timeout]); console.error("Connected."); const user: NDKUser = await signer.user(); - return { ndk, signer, user }; } diff --git a/test/csv.test.ts b/test/csv.test.ts new file mode 100644 index 0000000..4530293 --- /dev/null +++ b/test/csv.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from "bun:test"; +import { formatCSV, escapeField } from "../src/csv.js"; +import type { TransactionRecord } from "../src/types.js"; + +describe("escapeField", () => { + it("should return simple strings unchanged", () => { + expect(escapeField("hello")).toBe("hello"); + }); + + it("should quote fields containing commas", () => { + expect(escapeField("hello, world")).toBe('"hello, world"'); + }); + + it("should escape double quotes inside fields", () => { + expect(escapeField('say "hello"')).toBe('"say ""hello"""'); + }); + + it("should quote fields containing newlines", () => { + expect(escapeField("line1\nline2")).toBe('"line1\nline2"'); + }); + + it("should handle empty strings", () => { + expect(escapeField("")).toBe(""); + }); +}); + +describe("formatCSV", () => { + it("should produce correct header row for empty records", () => { + const csv = formatCSV([]); + expect(csv).toBe("date,type,amount,unit,mint,token_id,memo"); + }); + + it("should format a simple record", () => { + const records: TransactionRecord[] = [ + { + date: "2025-01-01T00:00:00Z", + type: "receive", + amount: 100, + unit: "sat", + mint: "https://mint.example", + token_id: "abc123", + memo: "", + }, + ]; + const lines = formatCSV(records).split("\n"); + expect(lines).toHaveLength(2); + expect(lines[1]).toContain("100"); + expect(lines[1]).toContain("receive"); + }); + + it("should escape fields containing commas", () => { + const records: TransactionRecord[] = [ + { + date: "2025-01-01T00:00:00Z", + type: "receive", + amount: 100, + unit: "sat", + mint: "https://mint.example", + token_id: "abc", + memo: "hello, world", + }, + ]; + const csv = formatCSV(records); + expect(csv).toContain('"hello, world"'); + }); + + it("should escape fields containing double quotes", () => { + const records: TransactionRecord[] = [ + { + date: "2025-01-01T00:00:00Z", + type: "receive", + amount: 100, + unit: "sat", + mint: "", + token_id: "abc", + memo: 'say "hello"', + }, + ]; + const csv = formatCSV(records); + expect(csv).toContain('"say ""hello"""'); + }); + + it("should sort records by date ascending", () => { + const records: TransactionRecord[] = [ + { + date: "2025-03-01T00:00:00Z", + type: "send", + amount: 50, + unit: "sat", + mint: "", + token_id: "b", + memo: "", + }, + { + date: "2025-01-01T00:00:00Z", + type: "receive", + amount: 100, + unit: "sat", + mint: "", + token_id: "a", + memo: "", + }, + ]; + const lines = formatCSV(records).split("\n"); + expect(lines[1]).toContain(",a,"); + expect(lines[2]).toContain(",b,"); + }); +}); diff --git a/test/events.test.ts b/test/events.test.ts new file mode 100644 index 0000000..ad7e3c5 --- /dev/null +++ b/test/events.test.ts @@ -0,0 +1,219 @@ +import { describe, it, expect } from "bun:test"; +import { buildTransactionHistory } from "../src/events.js"; +import type { + WalletData, + TokenData, + HistoryEntry, + TransactionRecord, +} from "../src/types.js"; + +describe("buildTransactionHistory", () => { + const emptyWallet: WalletData = { mints: [], privkey: "" }; + + it("should build records from history events", () => { + const history: HistoryEntry[] = [ + { + direction: "in", + amount: "100", + unit: "sat", + eventId: "evt1", + createdAt: 1000, + referencedEvents: [], + }, + { + direction: "out", + amount: "50", + unit: "sat", + eventId: "evt2", + createdAt: 2000, + referencedEvents: [], + }, + ]; + + const records = buildTransactionHistory(emptyWallet, [], history, [], []); + + expect(records).toHaveLength(2); + expect(records[0].type).toBe("receive"); + expect(records[0].amount).toBe(100); + expect(records[1].type).toBe("send"); + expect(records[1].amount).toBe(50); + }); + + it("should deduplicate by event ID across sources", () => { + const history: HistoryEntry[] = [ + { + direction: "in", + amount: "100", + unit: "sat", + eventId: "same-id", + createdAt: 1000, + referencedEvents: [], + }, + ]; + const nutzaps: TransactionRecord[] = [ + { + date: "2025-01-01T00:00:00Z", + type: "zap", + amount: 100, + unit: "sat", + mint: "", + token_id: "same-id", + memo: "", + }, + ]; + + const records = buildTransactionHistory( + emptyWallet, + [], + history, + nutzaps, + [] + ); + expect(records).toHaveLength(1); + }); + + it("should fall back to token events when no history events exist", () => { + const tokens: TokenData[] = [ + { + mint: "https://mint.example", + unit: "sat", + proofs: [{ id: "p1", amount: 64, secret: "s", C: "c" }], + eventId: "tok1", + createdAt: 1000, + }, + ]; + + const records = buildTransactionHistory(emptyWallet, tokens, [], [], []); + expect(records).toHaveLength(1); + expect(records[0].amount).toBe(64); + expect(records[0].type).toBe("receive"); + }); + + it("should NOT use token events when history events exist", () => { + const tokens: TokenData[] = [ + { + mint: "https://mint.example", + unit: "sat", + proofs: [{ id: "p1", amount: 64, secret: "s", C: "c" }], + eventId: "tok1", + createdAt: 1000, + }, + ]; + const history: HistoryEntry[] = [ + { + direction: "in", + amount: "100", + unit: "sat", + eventId: "evt1", + createdAt: 2000, + referencedEvents: [], + }, + ]; + + const records = buildTransactionHistory( + emptyWallet, + tokens, + history, + [], + [] + ); + expect(records).toHaveLength(1); + expect(records[0].token_id).toBe("evt1"); + }); + + it("should sort records by date ascending", () => { + const nutzaps: TransactionRecord[] = [ + { + date: new Date(3000 * 1000).toISOString(), + type: "zap", + amount: 10, + unit: "sat", + mint: "", + token_id: "z1", + memo: "", + }, + { + date: new Date(1000 * 1000).toISOString(), + type: "zap", + amount: 20, + unit: "sat", + mint: "", + token_id: "z2", + memo: "", + }, + ]; + + const records = buildTransactionHistory( + emptyWallet, + [], + [], + nutzaps, + [] + ); + expect(records[0].token_id).toBe("z2"); + expect(records[1].token_id).toBe("z1"); + }); + + it("should handle non-numeric amount gracefully (defaults to 0)", () => { + const history: HistoryEntry[] = [ + { + direction: "in", + amount: "not-a-number", + unit: "sat", + eventId: "evt1", + createdAt: 1000, + referencedEvents: [], + }, + ]; + + const records = buildTransactionHistory(emptyWallet, [], history, [], []); + expect(records[0].amount).toBe(0); + }); + + it("should merge nutzaps and redemptions with history", () => { + const history: HistoryEntry[] = [ + { + direction: "in", + amount: "100", + unit: "sat", + eventId: "h1", + createdAt: 1000, + referencedEvents: [], + }, + ]; + const nutzaps: TransactionRecord[] = [ + { + date: new Date(2000 * 1000).toISOString(), + type: "zap", + amount: 50, + unit: "sat", + mint: "", + token_id: "z1", + memo: "", + }, + ]; + const redemptions: TransactionRecord[] = [ + { + date: new Date(3000 * 1000).toISOString(), + type: "receive", + amount: 50, + unit: "sat", + mint: "", + token_id: "r1", + memo: "nutzap redemption", + }, + ]; + + const records = buildTransactionHistory( + emptyWallet, + [], + history, + nutzaps, + redemptions + ); + expect(records).toHaveLength(3); + expect(records[0].token_id).toBe("h1"); + expect(records[1].token_id).toBe("z1"); + expect(records[2].token_id).toBe("r1"); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index d26f0cd..97d7e0a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,5 +11,5 @@ "noEmit": true, "types": ["bun-types"] }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "test/**/*.ts"] }