Skip to content
Open
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
37 changes: 37 additions & 0 deletions src/dedup.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { createHash } from "crypto";

export class Deduplicator<T> {
private _inflight = new Map<string, Promise<T>>();
private _hits = 0;
Expand All @@ -24,3 +26,38 @@ export class Deduplicator<T> {
return { deduped: this._hits, total: this._hits + this._misses };
}
}

// In-memory key registry for idempotency tracking (#612)
const _knownKeys = new Set<string>();

/**
* 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();
}
11 changes: 9 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
26 changes: 25 additions & 1 deletion src/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,28 @@ export async function searchInvoices(
} catch (error) {
throw new SearchFailedError(error instanceof Error ? error.message : String(error));
}
}
}
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);
});
}
115 changes: 115 additions & 0 deletions test/dedup.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
56 changes: 56 additions & 0 deletions test/searchByMemo.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading