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();
}
22 changes: 19 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,8 +277,15 @@ export {
buildRevealTransactionFromStorage,
} from "./confidential.js";

export { Deduplicator } from "./dedup.js";

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

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

export { replayEvents } from "./events.js";
Expand Down Expand Up @@ -582,7 +589,8 @@ export type { WebhookRecord, WebhookReplayStore } from "./webhookReplay.js";
export {
createWebhookMiddleware,
generateWebhookSignature,
verifyWebhookSignature,
// verifyWebhookSignature moved to ./webhooks/verify.js

parseWebhookPayload,
isValidEventType,
isWebhookRequest,
Expand All @@ -607,6 +615,14 @@ export type {
InvoiceCancelledData,
InvoiceExpiredData,
} from "./webhookMiddleware.js";

// Standalone webhook signature verifier (#617)
export {
verifyWebhookSignature,
verifyWebhookSignatureOrThrow,
WebhookVerificationError,
} from "./webhooks/verify.js";

// ---------------------------------------------------------------------------
// Lazy factories for heavy modules
// ---------------------------------------------------------------------------
Expand Down
26 changes: 25 additions & 1 deletion src/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,28 @@ export async function searchInvoices(
} catch (error) {
throw new SearchFailedError(error instanceof Error ? error.message : String(error));
}
}
}
import type { Invoice } from "./types.js";

/**
* Search a local array of invoices by memo content.
*
* @param invoices - Array of invoices to search
* @param query - Substring to match against `invoice.memo`
* @param opts - Optional flags (caseSensitive defaults to false)
* @returns Invoices whose memo contains the query substring
*/
export function searchByMemo(
invoices: Invoice[],
query: string,
opts?: { caseSensitive?: boolean }
): Invoice[] {
if (!query) return invoices;

const target = opts?.caseSensitive ? query : query.toLowerCase();
return invoices.filter((invoice) => {
if (invoice.memo == null) return false;
const memo = opts?.caseSensitive ? invoice.memo : invoice.memo.toLowerCase();
return memo.includes(target);
});
}
57 changes: 45 additions & 12 deletions src/webhooks/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,29 +11,62 @@ import { createHmac, timingSafeEqual } from "crypto";
const HEX_PATTERN = /^[0-9a-f]+$/i;

/**
* Verifies the `X-Stellar-Split-Signature` header against the raw request
* body using a timing-safe comparison.
* Verifies a webhook payload against its HMAC-SHA256 signature.
*
* @param secret - The shared HMAC secret configured for the webhook.
* @param rawBody - The exact, unparsed request body bytes as received.
* @param signatureHeader - The hex-encoded signature from the request header.
* @returns `true` only when the computed digest matches the header value.
* @param payload - The raw request body / payload string.
* @param signature - The hex-encoded HMAC-SHA256 signature to verify.
* @param secret - The shared secret key.
* @returns `true` when the signature is valid, `false` otherwise.
* Never throws — malformed inputs return `false`.
*/
export function verifyWebhookSignature(
secret: string,
rawBody: string,
signatureHeader: string
payload: string,
signature: string,
secret: string
): boolean {
if (!HEX_PATTERN.test(signatureHeader) || signatureHeader.length % 2 !== 0) {
if (!HEX_PATTERN.test(signature) || signature.length % 2 !== 0) {
return false;
}

const expected = createHmac("sha256", secret).update(rawBody).digest();
const provided = Buffer.from(signatureHeader, "hex");
const expected = createHmac("sha256", secret).update(payload).digest();
const provided = Buffer.from(signature, "hex");

if (expected.length !== provided.length) {
return false;
}

return timingSafeEqual(expected, provided);
}

/**
* Thrown when a webhook signature fails verification.
*
* Wraps {@link verifyWebhookSignature} for consumers who prefer a throwing
* interface rather than checking a boolean return value.
*/
export class WebhookVerificationError extends Error {
constructor() {
super("Webhook signature verification failed");
this.name = "WebhookVerificationError";
Object.setPrototypeOf(this, new.target.prototype);
}
}

/**
* Verifies a webhook payload and throws {@link WebhookVerificationError}
* when the signature is invalid.
*
* @param payload - The raw request body / payload string.
* @param signature - The hex-encoded HMAC-SHA256 signature to verify.
* @param secret - The shared secret key.
* @throws {WebhookVerificationError} if the signature does not match.
*/
export function verifyWebhookSignatureOrThrow(
payload: string,
signature: string,
secret: string
): void {
if (!verifyWebhookSignature(payload, signature, secret)) {
throw new WebhookVerificationError();
}
}
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);
});
});
56 changes: 56 additions & 0 deletions test/searchByMemo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { searchByMemo } from "../src/search.js";
import type { Invoice } from "../src/types.js";

function makeInvoice(memo?: string): Invoice {
return {
id: "1",
creator: "GABC",
recipients: [],
token: "USDC",
deadline: 0,
memo,
} as Invoice;
}

describe("searchByMemo", () => {
const invoices = [
makeInvoice("split:INV-001"),
makeInvoice("SPLIT:inv-002"),
makeInvoice("payment for project alpha"),
makeInvoice(),
makeInvoice(""),
];

it("returns all invoices when query is empty", () => {
expect(searchByMemo(invoices, "")).toHaveLength(5);
});

it("finds invoices by substring (case-insensitive default)", () => {
const results = searchByMemo(invoices, "split");
expect(results).toHaveLength(2);
expect(results.map((i) => i.memo)).toContain("split:INV-001");
expect(results.map((i) => i.memo)).toContain("SPLIT:inv-002");
});

it("is case-sensitive when opts.caseSensitive is true", () => {
const results = searchByMemo(invoices, "split", { caseSensitive: true });
expect(results).toHaveLength(1);
expect(results[0].memo).toBe("split:INV-001");
});

it("skips invoices with undefined or null memo", () => {
const results = searchByMemo(invoices, "project");
expect(results).toHaveLength(1);
expect(results[0].memo).toBe("payment for project alpha");
});

it("matches partial strings", () => {
const results = searchByMemo(invoices, "alpha");
expect(results).toHaveLength(1);
expect(results[0].memo).toBe("payment for project alpha");
});

it("returns empty array when no matches", () => {
expect(searchByMemo(invoices, "nonexistent")).toHaveLength(0);
});
});
Loading
Loading