fix: Address review findings (CRITICAL/HIGH)
CRITICAL fixes: - Stop passing mint URLs as relay URLs in initNDK (protocol mismatch) - Add unit tests for buildTransactionHistory (dedup, fallback, sort) HIGH fixes: - Restore .claude entry in .gitignore - Add warning logging for skipped malformed proof tags in nutzap parsing - Add error handling + 30s timeout to NDK initialization - Add unit tests for formatCSV and escapeField - Export escapeField for testability Also: add test script to package.json, include test/ in tsconfig Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
bun.lockb
|
bun.lockb
|
||||||
|
.claude
|
||||||
|
.archon
|
||||||
|
|||||||
+2
-1
@@ -4,7 +4,8 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "bun run src/index.ts",
|
"start": "bun run src/index.ts",
|
||||||
"type-check": "bunx tsc --noEmit"
|
"type-check": "bunx tsc --noEmit",
|
||||||
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nostr-dev-kit/ndk": "latest"
|
"@nostr-dev-kit/ndk": "latest"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import type { TransactionRecord } from "./types.js";
|
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")) {
|
if (value.includes(",") || value.includes('"') || value.includes("\n")) {
|
||||||
return `"${value.replace(/"/g, '""')}"`;
|
return `"${value.replace(/"/g, '""')}"`;
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-1
@@ -171,14 +171,20 @@ export async function fetchNutzapEvents(
|
|||||||
try {
|
try {
|
||||||
const proofTags = event.getMatchingTags("proof");
|
const proofTags = event.getMatchingTags("proof");
|
||||||
let totalAmount = 0;
|
let totalAmount = 0;
|
||||||
|
let skippedProofs = 0;
|
||||||
for (const tag of proofTags) {
|
for (const tag of proofTags) {
|
||||||
try {
|
try {
|
||||||
const proof = JSON.parse(tag[1]);
|
const proof = JSON.parse(tag[1]);
|
||||||
totalAmount += proof.amount ?? 0;
|
totalAmount += proof.amount ?? 0;
|
||||||
} catch {
|
} 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 uTag = event.getMatchingTags("u");
|
||||||
const mint = uTag.length > 0 ? uTag[0][1] : "";
|
const mint = uTag.length > 0 ? uTag[0][1] : "";
|
||||||
|
|||||||
+2
-2
@@ -35,8 +35,8 @@ async function main() {
|
|||||||
? args[mintsIdx + 1].split(",").filter(Boolean)
|
? args[mintsIdx + 1].split(",").filter(Boolean)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
// Initialize NDK
|
// Initialize NDK (don't pass mints as relays — they use different protocols)
|
||||||
const { ndk, signer, user } = await initNDK(secretKey, extraMints);
|
const { ndk, signer, user } = await initNDK(secretKey);
|
||||||
|
|
||||||
// Fetch wallet data first (need mints for context)
|
// Fetch wallet data first (need mints for context)
|
||||||
const walletData = await fetchWalletData(ndk, user, signer);
|
const walletData = await fetchWalletData(ndk, user, signer);
|
||||||
|
|||||||
+16
-9
@@ -4,23 +4,30 @@ const DEFAULT_RELAYS = [
|
|||||||
"wss://relay.damus.io",
|
"wss://relay.damus.io",
|
||||||
"wss://relay.primal.net",
|
"wss://relay.primal.net",
|
||||||
"wss://nos.lol",
|
"wss://nos.lol",
|
||||||
"wss://relay.nostr.band",
|
"wss://purplepag.es",
|
||||||
|
"wss://indexer.coracle.social",
|
||||||
];
|
];
|
||||||
|
|
||||||
export async function initNDK(secretKey: string, extraRelays: string[] = []) {
|
export async function initNDK(secretKey: string, extraRelays: string[] = []) {
|
||||||
const signer = new NDKPrivateKeySigner(secretKey);
|
let signer: NDKPrivateKeySigner;
|
||||||
const relays = [...new Set([...DEFAULT_RELAYS, ...extraRelays])];
|
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({
|
const relays = [...new Set([...DEFAULT_RELAYS, ...extraRelays])];
|
||||||
explicitRelayUrls: relays,
|
const ndk = new NDK({ explicitRelayUrls: relays, signer });
|
||||||
signer,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.error(`Connecting to ${relays.length} relays...`);
|
console.error(`Connecting to ${relays.length} relays...`);
|
||||||
await ndk.connect();
|
const timeout = new Promise<never>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error("Relay connection timed out after 30s")), 30000)
|
||||||
|
);
|
||||||
|
await Promise.race([ndk.connect(), timeout]);
|
||||||
console.error("Connected.");
|
console.error("Connected.");
|
||||||
|
|
||||||
const user: NDKUser = await signer.user();
|
const user: NDKUser = await signer.user();
|
||||||
|
|
||||||
return { ndk, signer, user };
|
return { ndk, signer, user };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
+1
-1
@@ -11,5 +11,5 @@
|
|||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"types": ["bun-types"]
|
"types": ["bun-types"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"]
|
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user