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..a68bda2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -277,7 +277,13 @@ export { buildRevealTransactionFromStorage, } from "./confidential.js"; -export { Deduplicator } from "./dedup.js"; +export { + Deduplicator, + generateIdempotencyKey, + isKnownKey, + registerKey, + clearKeys, +} from "./dedup.js"; export { TxQueue } from "./queue.js"; 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); + }); +});