diff --git a/src/dedup.ts b/src/dedup.ts index 485edcd..665b035 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,64 @@ export class Deduplicator { return { deduped: this._hits, total: this._hits + this._misses }; } } + +/** + * Parameters used to derive a deterministic idempotency key for payments. + */ +export interface IdempotencyParams { + /** Unique identifier of the invoice being paid. */ + invoiceId: string; + /** Stellar address of the payer. */ + payer: string; + /** Payment amount in stroops/smallest unit. */ + amount: bigint; + /** Optional client-provided nonce or entropy token. */ + nonce?: string; +} + +const registeredKeys = new Set(); + +/** + * Generates a canonical, deterministic idempotency key for payment deduplication. + * + * Computes the SHA-256 hex digest of `"{invoiceId}:{payer}:{amount}"` + * or `"{invoiceId}:{payer}:{amount}:{nonce}"` if a nonce is provided. + * + * @param params - The payment parameters: invoiceId, payer, amount, and optional nonce. + * @returns Deterministic 64-character hex SHA-256 hash. + */ +export function generateIdempotencyKey(params: IdempotencyParams): string { + const { invoiceId, payer, amount, nonce } = params; + const canonical = + nonce !== undefined && nonce !== null && nonce !== "" + ? `${invoiceId}:${payer}:${amount.toString()}:${nonce}` + : `${invoiceId}:${payer}:${amount.toString()}`; + + return createHash("sha256").update(canonical).digest("hex"); +} + +/** + * Returns `true` if the idempotency key has already been registered in memory. + * + * @param key - The 64-char hex idempotency key to check. + */ +export function isKnownKey(key: string): boolean { + return registeredKeys.has(key); +} + +/** + * Registers an idempotency key in memory to prevent duplicate executions. + * + * @param key - The 64-char hex idempotency key to register. + */ +export function registerKey(key: string): void { + registeredKeys.add(key); +} + +/** + * Clears all registered idempotency keys from memory (useful for test teardown). + */ +export function clearKeys(): void { + registeredKeys.clear(); +} + diff --git a/src/index.ts b/src/index.ts index ebf7e30..5813849 100644 --- a/src/index.ts +++ b/src/index.ts @@ -277,7 +277,14 @@ export { buildRevealTransactionFromStorage, } from "./confidential.js"; -export { Deduplicator } from "./dedup.js"; +export { + Deduplicator, + generateIdempotencyKey, + isKnownKey, + registerKey, + clearKeys, +} from "./dedup.js"; +export type { IdempotencyParams } 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..d247995 --- /dev/null +++ b/test/dedup.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + Deduplicator, + generateIdempotencyKey, + isKnownKey, + registerKey, + clearKeys, +} from "../src/dedup.js"; +import * as IndexExports from "../src/index.js"; + +describe("Deduplication & Idempotency Key Engine (Issue #612)", () => { + beforeEach(() => { + clearKeys(); + }); + + it("exports idempotency functions from index.ts", () => { + expect(typeof IndexExports.generateIdempotencyKey).toBe("function"); + expect(typeof IndexExports.isKnownKey).toBe("function"); + expect(typeof IndexExports.registerKey).toBe("function"); + expect(typeof IndexExports.clearKeys).toBe("function"); + expect(IndexExports.Deduplicator).toBeDefined(); + }); + + it("generates deterministic 64-character hex SHA-256 key from identical parameters", () => { + const params1 = { + invoiceId: "inv_123456", + payer: "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFTGOBJZ5GYWMSZ6TRECE", + amount: 10000000n, + }; + const params2 = { + invoiceId: "inv_123456", + payer: "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFTGOBJZ5GYWMSZ6TRECE", + amount: 10000000n, + }; + + const key1 = generateIdempotencyKey(params1); + const key2 = generateIdempotencyKey(params2); + + expect(key1).toBe(key2); + expect(key1).toMatch(/^[0-9a-f]{64}$/); + }); + + it("produces distinct keys when amount differs", () => { + const key1 = generateIdempotencyKey({ + invoiceId: "inv_100", + payer: "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFTGOBJZ5GYWMSZ6TRECE", + amount: 500n, + }); + const key2 = generateIdempotencyKey({ + invoiceId: "inv_100", + payer: "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFTGOBJZ5GYWMSZ6TRECE", + amount: 501n, + }); + + expect(key1).not.toBe(key2); + }); + + it("produces distinct keys when invoiceId differs", () => { + const key1 = generateIdempotencyKey({ + invoiceId: "inv_aaa", + payer: "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFTGOBJZ5GYWMSZ6TRECE", + amount: 1000n, + }); + const key2 = generateIdempotencyKey({ + invoiceId: "inv_bbb", + payer: "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFTGOBJZ5GYWMSZ6TRECE", + amount: 1000n, + }); + + expect(key1).not.toBe(key2); + }); + + it("produces distinct keys when payer differs", () => { + const key1 = generateIdempotencyKey({ + invoiceId: "inv_1", + payer: "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFTGOBJZ5GYWMSZ6TRECE", + amount: 1000n, + }); + const key2 = generateIdempotencyKey({ + invoiceId: "inv_1", + payer: "GCKICEQ2SA6K6SQ7UGLUP3GHW5WGYE6GYX3GDFQ527V4MGFE6Z3Z2RDT", + amount: 1000n, + }); + + expect(key1).not.toBe(key2); + }); + + it("changes key when nonce is provided", () => { + const baseParams = { + invoiceId: "inv_nonce_test", + payer: "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFTGOBJZ5GYWMSZ6TRECE", + amount: 2500000n, + }; + + const keyWithoutNonce = generateIdempotencyKey(baseParams); + const keyWithNonce1 = generateIdempotencyKey({ ...baseParams, nonce: "nonce-abc" }); + const keyWithNonce2 = generateIdempotencyKey({ ...baseParams, nonce: "nonce-xyz" }); + + expect(keyWithoutNonce).not.toBe(keyWithNonce1); + expect(keyWithNonce1).not.toBe(keyWithNonce2); + }); + + it("registers and tracks known keys correctly", () => { + const key = generateIdempotencyKey({ + invoiceId: "inv_register", + payer: "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFTGOBJZ5GYWMSZ6TRECE", + amount: 10000n, + }); + + expect(isKnownKey(key)).toBe(false); + registerKey(key); + expect(isKnownKey(key)).toBe(true); + }); + + it("clears registered keys on clearKeys()", () => { + const key1 = "test_key_1"; + const key2 = "test_key_2"; + + 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); + }); + + it("retains existing Deduplicator inflight deduplication functionality", async () => { + const deduplicator = new Deduplicator(); + let executionCount = 0; + + const mockFetch = async () => { + executionCount++; + return "result_data"; + }; + + const [res1, res2] = await Promise.all([ + deduplicator.dedupe("req_key", mockFetch), + deduplicator.dedupe("req_key", mockFetch), + ]); + + expect(res1).toBe("result_data"); + expect(res2).toBe("result_data"); + expect(executionCount).toBe(1); + expect(deduplicator.cacheHitRate).toBe(0.5); + expect(deduplicator.getDedupStats()).toEqual({ deduped: 1, total: 2 }); + }); +});