diff --git a/src/dedup.ts b/src/dedup.ts index 485edcd..4357afd 100644 --- a/src/dedup.ts +++ b/src/dedup.ts @@ -1,3 +1,5 @@ +import { createHash } from "crypto"; + export class Deduplicator { private _inflight = new Map>(); private _hits = 0; @@ -24,3 +26,38 @@ export class Deduplicator { return { deduped: this._hits, total: this._hits + this._misses }; } } + +// In-memory key registry for idempotency tracking (#612) +const _knownKeys = new Set(); + +/** + * Generates a deterministic idempotency key from payment parameters. + * The key is a SHA-256 hex digest of `"{invoiceId}:{payer}:{amount}"` + * with an optional `:{nonce}` suffix when provided. + */ +export function generateIdempotencyKey(params: { + invoiceId: string; + payer: string; + amount: bigint; + nonce?: string; +}): string { + const payload = params.nonce + ? `${params.invoiceId}:${params.payer}:${params.amount}:${params.nonce}` + : `${params.invoiceId}:${params.payer}:${params.amount}`; + return createHash("sha256").update(payload).digest("hex"); +} + +/** Returns true if the key has already been registered. */ +export function isKnownKey(key: string): boolean { + return _knownKeys.has(key); +} + +/** Registers a key as known (idempotent). */ +export function registerKey(key: string): void { + _knownKeys.add(key); +} + +/** Clears the in-memory key registry. Intended for test teardown. */ +export function clearKeys(): void { + _knownKeys.clear(); +} diff --git a/src/index.ts b/src/index.ts index ebf7e30..58a11ba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -277,8 +277,15 @@ export { buildRevealTransactionFromStorage, } from "./confidential.js"; -export { Deduplicator } from "./dedup.js"; - +export { + Deduplicator, + generateIdempotencyKey, + isKnownKey, + registerKey, + clearKeys, +} from "./dedup.js"; + +export { searchByMemo } from "./search.js"; export { TxQueue } from "./queue.js"; export { replayEvents } from "./events.js"; diff --git a/src/search.ts b/src/search.ts index 8d84608..f197dec 100644 --- a/src/search.ts +++ b/src/search.ts @@ -45,4 +45,28 @@ export async function searchInvoices( } catch (error) { throw new SearchFailedError(error instanceof Error ? error.message : String(error)); } -} \ No newline at end of file +} +import type { Invoice } from "./types.js"; + +/** + * Search a local array of invoices by memo content. + * + * @param invoices - Array of invoices to search + * @param query - Substring to match against `invoice.memo` + * @param opts - Optional flags (caseSensitive defaults to false) + * @returns Invoices whose memo contains the query substring + */ +export function searchByMemo( + invoices: Invoice[], + query: string, + opts?: { caseSensitive?: boolean } +): Invoice[] { + if (!query) return invoices; + + const target = opts?.caseSensitive ? query : query.toLowerCase(); + return invoices.filter((invoice) => { + if (invoice.memo == null) return false; + const memo = opts?.caseSensitive ? invoice.memo : invoice.memo.toLowerCase(); + return memo.includes(target); + }); +} diff --git a/test/dedup.test.ts b/test/dedup.test.ts new file mode 100644 index 0000000..29c0d42 --- /dev/null +++ b/test/dedup.test.ts @@ -0,0 +1,115 @@ +import { + generateIdempotencyKey, + isKnownKey, + registerKey, + clearKeys, +} from "../src/dedup.js"; + +describe("generateIdempotencyKey", () => { + afterEach(() => { + clearKeys(); + }); + + it("produces the same key for identical inputs", () => { + const params = { + invoiceId: "inv-123", + payer: "GABC123", + amount: 1000n, + }; + const key1 = generateIdempotencyKey(params); + const key2 = generateIdempotencyKey(params); + expect(key1).toBe(key2); + expect(key1).toMatch(/^[a-f0-9]{64}$/); + }); + + it("produces different keys for different amounts", () => { + const key1 = generateIdempotencyKey({ + invoiceId: "inv-123", + payer: "GABC123", + amount: 1000n, + }); + const key2 = generateIdempotencyKey({ + invoiceId: "inv-123", + payer: "GABC123", + amount: 2000n, + }); + expect(key1).not.toBe(key2); + }); + + it("produces different keys for different payers", () => { + const key1 = generateIdempotencyKey({ + invoiceId: "inv-123", + payer: "GABC123", + amount: 1000n, + }); + const key2 = generateIdempotencyKey({ + invoiceId: "inv-123", + payer: "GDEF456", + amount: 1000n, + }); + expect(key1).not.toBe(key2); + }); + + it("produces different keys for different invoiceIds", () => { + const key1 = generateIdempotencyKey({ + invoiceId: "inv-123", + payer: "GABC123", + amount: 1000n, + }); + const key2 = generateIdempotencyKey({ + invoiceId: "inv-456", + payer: "GABC123", + amount: 1000n, + }); + expect(key1).not.toBe(key2); + }); + + it("changes the key when a nonce is provided", () => { + const base = generateIdempotencyKey({ + invoiceId: "inv-123", + payer: "GABC123", + amount: 1000n, + }); + const withNonce = generateIdempotencyKey({ + invoiceId: "inv-123", + payer: "GABC123", + amount: 1000n, + nonce: "abc", + }); + expect(withNonce).not.toBe(base); + expect(withNonce).toMatch(/^[a-f0-9]{64}$/); + }); + + it("produces the same key for the same nonce", () => { + const params = { + invoiceId: "inv-123", + payer: "GABC123", + amount: 1000n, + nonce: "xyz", + }; + expect(generateIdempotencyKey(params)).toBe(generateIdempotencyKey(params)); + }); +}); + +describe("key registry", () => { + afterEach(() => { + clearKeys(); + }); + + it("returns false for unknown keys", () => { + expect(isKnownKey("unknown")).toBe(false); + }); + + it("returns true after registering a key", () => { + registerKey("my-key"); + expect(isKnownKey("my-key")).toBe(true); + }); + + it("clears all keys", () => { + registerKey("a"); + registerKey("b"); + clearKeys(); + expect(isKnownKey("a")).toBe(false); + expect(isKnownKey("b")).toBe(false); + }); +}); diff --git a/test/searchByMemo.test.ts b/test/searchByMemo.test.ts new file mode 100644 index 0000000..87f1064 --- /dev/null +++ b/test/searchByMemo.test.ts @@ -0,0 +1,56 @@ +import { searchByMemo } from "../src/search.js"; +import type { Invoice } from "../src/types.js"; + +function makeInvoice(memo?: string): Invoice { + return { + id: "1", + creator: "GABC", + recipients: [], + token: "USDC", + deadline: 0, + memo, + } as Invoice; +} + +describe("searchByMemo", () => { + const invoices = [ + makeInvoice("split:INV-001"), + makeInvoice("SPLIT:inv-002"), + makeInvoice("payment for project alpha"), + makeInvoice(), + makeInvoice(""), + ]; + + it("returns all invoices when query is empty", () => { + expect(searchByMemo(invoices, "")).toHaveLength(5); + }); + + it("finds invoices by substring (case-insensitive default)", () => { + const results = searchByMemo(invoices, "split"); + expect(results).toHaveLength(2); + expect(results.map((i) => i.memo)).toContain("split:INV-001"); + expect(results.map((i) => i.memo)).toContain("SPLIT:inv-002"); + }); + + it("is case-sensitive when opts.caseSensitive is true", () => { + const results = searchByMemo(invoices, "split", { caseSensitive: true }); + expect(results).toHaveLength(1); + expect(results[0].memo).toBe("split:INV-001"); + }); + + it("skips invoices with undefined or null memo", () => { + const results = searchByMemo(invoices, "project"); + expect(results).toHaveLength(1); + expect(results[0].memo).toBe("payment for project alpha"); + }); + + it("matches partial strings", () => { + const results = searchByMemo(invoices, "alpha"); + expect(results).toHaveLength(1); + expect(results[0].memo).toBe("payment for project alpha"); + }); + + it("returns empty array when no matches", () => { + expect(searchByMemo(invoices, "nonexistent")).toHaveLength(0); + }); +});