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
55 changes: 55 additions & 0 deletions src/dedup.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { createHash } from "node:crypto";

export class Deduplicator<T> {
private _inflight = new Map<string, Promise<T>>();
private _hits = 0;
Expand All @@ -24,3 +26,56 @@ export class Deduplicator<T> {
return { deduped: this._hits, total: this._hits + this._misses };
}
}

/** @internal In-memory set of known idempotency keys. */
const _knownKeys = new Set<string>();

/**
* Parameters for building a canonical idempotency key.
*/
export interface GenerateIdempotencyKeyParams {
/** The invoice or resource identifier. */
invoiceId: string;
/** The payer account address. */
payer: string;
/** The payment amount as a bigint to avoid floating-point ambiguity. */
amount: bigint;
/** Optional nonce to force a distinct key for otherwise identical submissions. */
nonce?: string;
}

/**
* Build a deterministic idempotency key from the given parameters.
*
* The key is a SHA-256 hex digest of the canonical string
* `"{invoiceId}:{payer}:{amount}"` with an optional `:{nonce}` suffix.
* Same inputs always produce the same output; different inputs produce
* different outputs with high probability.
*/
export function generateIdempotencyKey(params: GenerateIdempotencyKeyParams): string {
const { invoiceId, payer, amount, nonce } = params;
const base = `${invoiceId}:${payer}:${amount}`;
const input = nonce !== undefined ? `${base}:${nonce}` : base;
return createHash("sha256").update(input, "utf-8").digest("hex");
}

/**
* Check whether `key` has already been registered.
*/
export function isKnownKey(key: string): boolean {
return _knownKeys.has(key);
}

/**
* Register `key` in the in-memory known-key set.
*/
export function registerKey(key: string): void {
_knownKeys.add(key);
}

/**
* Clear all known keys — intended for test teardown.
*/
export function clearKeys(): void {
_knownKeys.clear();
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,8 @@ export {
} from "./confidential.js";

export { Deduplicator } from "./dedup.js";
export { generateIdempotencyKey, isKnownKey, registerKey, clearKeys } from "./dedup.js";
export type { GenerateIdempotencyKeyParams } from "./dedup.js";

export { TxQueue } from "./queue.js";

Expand Down
93 changes: 93 additions & 0 deletions test/idempotencyKey.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// @vitest-environment node
import { describe, expect, it, beforeEach, afterEach } from "vitest";
import {
generateIdempotencyKey,
isKnownKey,
registerKey,
clearKeys,
} from "../src/dedup.js";

describe("generateIdempotencyKey", () => {
const params = {
invoiceId: "inv-001",
payer: "GCZST3XVCDTUJ76ZAV2HA72KYTZ4KXX52HRXVWWRWXH2NBDXZWQS2FB2",
amount: 10000000n,
};

it("produces a deterministic hex string for the same inputs", () => {
const key1 = generateIdempotencyKey(params);
const key2 = generateIdempotencyKey(params);
expect(key1).toBe(key2);
expect(key1).toMatch(/^[0-9a-f]{64}$/);
});

it("produces different keys for different amounts", () => {
const key1 = generateIdempotencyKey({ ...params, amount: 10000000n });
const key2 = generateIdempotencyKey({ ...params, amount: 20000000n });
expect(key1).not.toBe(key2);
});

it("produces different keys for different payers", () => {
const key1 = generateIdempotencyKey(params);
const key2 = generateIdempotencyKey({
...params,
payer: "GBRPYHIL2CI3WHSCULNJJMA3CJBYWR5LK662LFXISKW3P7UKDXTX",
});
expect(key1).not.toBe(key2);
});

it("includes nonce when provided", () => {
const key1 = generateIdempotencyKey({ ...params, nonce: "abc" });
const key2 = generateIdempotencyKey({ ...params, nonce: "def" });
expect(key1).not.toBe(key2);
});

it("same params with nonce but different nonce produces different keys", () => {
const key1 = generateIdempotencyKey(params);
const key2 = generateIdempotencyKey({ ...params, nonce: "retry-1" });
expect(key1).not.toBe(key2);
});
});

describe("isKnownKey / registerKey / clearKeys", () => {
beforeEach(() => {
clearKeys();
});

afterEach(() => {
clearKeys();
});

it("isKnownKey returns false for unregistered keys", () => {
expect(isKnownKey("unknown-key")).toBe(false);
});

it("isKnownKey returns true after registerKey", () => {
const key = generateIdempotencyKey({
invoiceId: "inv-002",
payer: "GCZST3XVCDTUJ76ZAV2HA72KYTZ4KXX52HRXVWWRWXH2NBDXZWQS2FB2",
amount: 5000000n,
});
expect(isKnownKey(key)).toBe(false);
registerKey(key);
expect(isKnownKey(key)).toBe(true);
});

it("clearKeys resets the known-key set", () => {
const key = "some-test-key";
registerKey(key);
expect(isKnownKey(key)).toBe(true);
clearKeys();
expect(isKnownKey(key)).toBe(false);
});

it("multiple keys can be registered and checked independently", () => {
const key1 = "key-a";
const key2 = "key-b";
registerKey(key1);
expect(isKnownKey(key1)).toBe(true);
expect(isKnownKey(key2)).toBe(false);
registerKey(key2);
expect(isKnownKey(key2)).toBe(true);
});
});