From e4d54cfb5e80cc7d034a5776931834d81bf2583c Mon Sep 17 00:00:00 2001 From: s6pa1rta3n-lab Date: Tue, 1 Sep 2026 08:10:04 -0400 Subject: [PATCH] feat(dedup): add generateIdempotencyKey and canonical key registry (closes #612) --- src/dedup.ts | 48 ++++++++++ src/index.ts | 9 +- test/dedup.test.ts | 213 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 test/dedup.test.ts diff --git a/src/dedup.ts b/src/dedup.ts index 485edcd..c138c18 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,49 @@ export class Deduplicator { return { deduped: this._hits, total: this._hits + this._misses }; } } + +export interface IdempotencyKeyParams { + invoiceId: string; + payer: string; + amount: bigint; + nonce?: string; +} + +const knownKeys = new Set(); + +/** + * Generates a canonical, deterministic idempotency key for payment deduplication. + * Returns a SHA-256 hex string of "{invoiceId}:{payer}:{amount}" (with optional ":{nonce}" suffix). + */ +export function generateIdempotencyKey(params: { + invoiceId: string; + payer: string; + amount: bigint; + nonce?: string; +}): string { + const base = `${params.invoiceId}:${params.payer}:${params.amount.toString()}`; + const payload = params.nonce !== undefined ? `${base}:${params.nonce}` : base; + return createHash("sha256").update(payload).digest("hex"); +} + +/** + * Checks if an idempotency key is already registered. + */ +export function isKnownKey(key: string): boolean { + return knownKeys.has(key); +} + +/** + * Registers an idempotency key in the module's in-memory set. + */ +export function registerKey(key: string): void { + knownKeys.add(key); +} + +/** + * Clears all registered keys (primarily used for test teardown). + */ +export function clearKeys(): void { + knownKeys.clear(); +} + diff --git a/src/index.ts b/src/index.ts index b030cf6..b7a5f85 100644 --- a/src/index.ts +++ b/src/index.ts @@ -289,7 +289,14 @@ export { buildRevealTransactionFromStorage, } from "./confidential.js"; -export { Deduplicator } from "./dedup.js"; +export { + Deduplicator, + generateIdempotencyKey, + isKnownKey, + registerKey, + clearKeys, +} from "./dedup.js"; +export type { IdempotencyKeyParams } 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..559da9b --- /dev/null +++ b/test/dedup.test.ts @@ -0,0 +1,213 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { createHash } from "crypto"; +import { + generateIdempotencyKey, + isKnownKey, + registerKey, + clearKeys, + Deduplicator, +} from "../src/dedup.js"; +import * as rootExports from "../src/index.js"; + +describe("dedup module - generateIdempotencyKey & Key Registry", () => { + beforeEach(() => { + clearKeys(); + }); + + describe("generateIdempotencyKey", () => { + it("generates deterministic keys for identical parameters without nonce", () => { + const params = { + invoiceId: "inv_12345", + payer: "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFXYORTMXZAXGIIO7NUM9", + amount: 10000000n, + }; + + const key1 = generateIdempotencyKey(params); + const key2 = generateIdempotencyKey(params); + + expect(key1).toBe(key2); + expect(key1).toMatch(/^[a-f0-9]{64}$/); + + const expectedPayload = `${params.invoiceId}:${params.payer}:${params.amount.toString()}`; + const expectedHash = createHash("sha256").update(expectedPayload).digest("hex"); + expect(key1).toBe(expectedHash); + }); + + it("generates deterministic keys for identical parameters with nonce", () => { + const params = { + invoiceId: "inv_12345", + payer: "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFXYORTMXZAXGIIO7NUM9", + amount: 10000000n, + nonce: "nonce_abc", + }; + + const key1 = generateIdempotencyKey(params); + const key2 = generateIdempotencyKey(params); + + expect(key1).toBe(key2); + expect(key1).toMatch(/^[a-f0-9]{64}$/); + + const expectedPayload = `${params.invoiceId}:${params.payer}:${params.amount.toString()}:${params.nonce}`; + const expectedHash = createHash("sha256").update(expectedPayload).digest("hex"); + expect(key1).toBe(expectedHash); + }); + + it("produces different keys when amount differs", () => { + const baseParams = { + invoiceId: "inv_100", + payer: "GABC", + }; + + const key1 = generateIdempotencyKey({ ...baseParams, amount: 100n }); + const key2 = generateIdempotencyKey({ ...baseParams, amount: 200n }); + + expect(key1).not.toBe(key2); + }); + + it("produces different keys when invoiceId differs", () => { + const baseParams = { + payer: "GABC", + amount: 100n, + }; + + const key1 = generateIdempotencyKey({ ...baseParams, invoiceId: "inv_1" }); + const key2 = generateIdempotencyKey({ ...baseParams, invoiceId: "inv_2" }); + + expect(key1).not.toBe(key2); + }); + + it("produces different keys when payer differs", () => { + const baseParams = { + invoiceId: "inv_1", + amount: 100n, + }; + + const key1 = generateIdempotencyKey({ ...baseParams, payer: "GA" }); + const key2 = generateIdempotencyKey({ ...baseParams, payer: "GB" }); + + expect(key1).not.toBe(key2); + }); + + it("produces different keys when nonce is provided vs omitted", () => { + const baseParams = { + invoiceId: "inv_100", + payer: "GABC", + amount: 500n, + }; + + const keyWithoutNonce = generateIdempotencyKey(baseParams); + const keyWithNonce = generateIdempotencyKey({ ...baseParams, nonce: "n1" }); + + expect(keyWithoutNonce).not.toBe(keyWithNonce); + }); + + it("produces different keys for different nonce values", () => { + const baseParams = { + invoiceId: "inv_100", + payer: "GABC", + amount: 500n, + }; + + const key1 = generateIdempotencyKey({ ...baseParams, nonce: "n1" }); + const key2 = generateIdempotencyKey({ ...baseParams, nonce: "n2" }); + + expect(key1).not.toBe(key2); + }); + }); + + describe("isKnownKey, registerKey, clearKeys", () => { + it("returns false for unregistered key", () => { + const key = generateIdempotencyKey({ + invoiceId: "inv_1", + payer: "GABC", + amount: 100n, + }); + + expect(isKnownKey(key)).toBe(false); + }); + + it("returns true for registered key after registerKey", () => { + const key = generateIdempotencyKey({ + invoiceId: "inv_1", + payer: "GABC", + amount: 100n, + }); + + registerKey(key); + expect(isKnownKey(key)).toBe(true); + }); + + it("does not affect other keys when one key is registered", () => { + const key1 = generateIdempotencyKey({ + invoiceId: "inv_1", + payer: "GABC", + amount: 100n, + }); + const key2 = generateIdempotencyKey({ + invoiceId: "inv_2", + payer: "GABC", + amount: 100n, + }); + + registerKey(key1); + expect(isKnownKey(key1)).toBe(true); + expect(isKnownKey(key2)).toBe(false); + }); + + it("clearKeys resets state so registered keys return false", () => { + const key1 = generateIdempotencyKey({ + invoiceId: "inv_1", + payer: "GABC", + amount: 100n, + }); + const key2 = generateIdempotencyKey({ + invoiceId: "inv_2", + payer: "GDEF", + amount: 200n, + }); + + registerKey(key1); + registerKey(key2); + expect(isKnownKey(key1)).toBe(true); + expect(isKnownKey(key2)).toBe(true); + + clearKeys(); + + expect(isKnownKey(key1)).toBe(false); + expect(isKnownKey(key2)).toBe(false); + }); + }); + + describe("Root exports check", () => { + it("exports generateIdempotencyKey, isKnownKey, registerKey, clearKeys, and Deduplicator from root index", () => { + expect(typeof rootExports.generateIdempotencyKey).toBe("function"); + expect(typeof rootExports.isKnownKey).toBe("function"); + expect(typeof rootExports.registerKey).toBe("function"); + expect(typeof rootExports.clearKeys).toBe("function"); + expect(typeof rootExports.Deduplicator).toBe("function"); + }); + }); + + describe("Deduplicator existing functionality", () => { + it("deduplicates concurrent async function calls", async () => { + const deduplicator = new Deduplicator(); + let callCount = 0; + + const slowFn = async () => { + callCount++; + return "result-1"; + }; + + const [res1, res2] = await Promise.all([ + deduplicator.dedupe("key1", slowFn), + deduplicator.dedupe("key1", slowFn), + ]); + + expect(res1).toBe("result-1"); + expect(res2).toBe("result-1"); + expect(callCount).toBe(1); + expect(deduplicator.getDedupStats()).toEqual({ deduped: 1, total: 2 }); + expect(deduplicator.cacheHitRate).toBe(0.5); + }); + }); +});