Skip to content
Closed
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
63 changes: 63 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,64 @@ export class Deduplicator<T> {
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<string>();

/**
* 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();
}

9 changes: 8 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
149 changes: 149 additions & 0 deletions test/dedup.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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 });
});
});