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

Expand Down
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);
});
});
Loading