From 71f56d198ec1bd59521b3c7906aa73c5f0a9c677 Mon Sep 17 00:00:00 2001 From: Ogunmodede Joel Taiwo Date: Sat, 17 Oct 2026 22:03:03 +0100 Subject: [PATCH] fix(wallet): cap InMemorySigningHistoryStore entries to prevent unbounded growth (#743) Add configurable capacity limit to InMemorySigningHistoryStore with a default of 1000 entries. Oldest records are dropped on overflow (FIFO order), ensuring query() returns at most the capped count. The cap is tunable via constructor options without breaking existing interfaces. --- src/index.ts | 2 + src/tests/wallet.test.ts | 80 ++++++++++++++++++++++++++++++++++++ src/wallet/index.ts | 2 + src/wallet/signingHistory.ts | 45 ++++++++++++++++++++ 4 files changed, 129 insertions(+) diff --git a/src/index.ts b/src/index.ts index 3bd092a2..e64cab20 100644 --- a/src/index.ts +++ b/src/index.ts @@ -212,11 +212,13 @@ export { getSigningHistory, exportSigningHistory, InMemorySigningHistoryStore, + DEFAULT_SIGNING_HISTORY_MAX_ENTRIES, } from "./wallet/signingHistory"; export type { SigningRecord, SigningHistoryFilter, SigningHistoryStore, + InMemorySigningHistoryStoreOptions, } from "./wallet/signingHistory"; // ─── Wallet connection throttling and abuse detection (#506) ────────────────── diff --git a/src/tests/wallet.test.ts b/src/tests/wallet.test.ts index 25788219..7346c1ee 100644 --- a/src/tests/wallet.test.ts +++ b/src/tests/wallet.test.ts @@ -36,6 +36,7 @@ import { InMemorySigningHistoryStore, getSigningHistory, exportSigningHistory, + DEFAULT_SIGNING_HISTORY_MAX_ENTRIES, type SigningRecord, } from "../wallet/signingHistory"; import { @@ -943,6 +944,85 @@ describe("signing history export with filters (#387)", () => { expect(parsed[0]).not.toHaveProperty("privateKey"); } }); + + describe("InMemorySigningHistoryStore size limit and overflow (#743)", () => { + it("defaults to a maximum of 1000 entries and reports maxEntries", () => { + const store = new InMemorySigningHistoryStore(); + expect(store.getMaxEntries()).toBe(1000); + expect(DEFAULT_SIGNING_HISTORY_MAX_ENTRIES).toBe(1000); + expect(store.size).toBe(0); + }); + + it("supports custom maxEntries via number or options object", () => { + const storeByNum = new InMemorySigningHistoryStore(50); + expect(storeByNum.getMaxEntries()).toBe(50); + + const storeByObj = new InMemorySigningHistoryStore({ maxEntries: 250 }); + expect(storeByObj.getMaxEntries()).toBe(250); + + // Falls back to default if invalid/negative value is passed + const storeInvalid = new InMemorySigningHistoryStore({ maxEntries: -10 }); + expect(storeInvalid.getMaxEntries()).toBe(DEFAULT_SIGNING_HISTORY_MAX_ENTRIES); + + const storeZero = new InMemorySigningHistoryStore(0); + expect(storeZero.getMaxEntries()).toBe(DEFAULT_SIGNING_HISTORY_MAX_ENTRIES); + }); + + it("evicts the oldest entries in FIFO order when capacity is exceeded", () => { + const store = new InMemorySigningHistoryStore(3); + + store.record({ txHash: "tx1", signer: "G1", timestamp: "2026-01-01T00:00:00Z", status: "success" }); + store.record({ txHash: "tx2", signer: "G1", timestamp: "2026-01-02T00:00:00Z", status: "success" }); + store.record({ txHash: "tx3", signer: "G1", timestamp: "2026-01-03T00:00:00Z", status: "failure" }); + + expect(store.size).toBe(3); + expect(store.query().map((r) => r.txHash)).toEqual(["tx1", "tx2", "tx3"]); + + // Adding 4th record evicts oldest ("tx1") + store.record({ txHash: "tx4", signer: "G1", timestamp: "2026-01-04T00:00:00Z", status: "success" }); + expect(store.size).toBe(3); + expect(store.query().map((r) => r.txHash)).toEqual(["tx2", "tx3", "tx4"]); + + // Adding 5th record evicts oldest ("tx2") + store.record({ txHash: "tx5", signer: "G1", timestamp: "2026-01-05T00:00:00Z", status: "success" }); + expect(store.size).toBe(3); + expect(store.query().map((r) => r.txHash)).toEqual(["tx3", "tx4", "tx5"]); + }); + + it("query() returns at most the capped count of records", () => { + const store = new InMemorySigningHistoryStore(5); + + for (let i = 1; i <= 20; i++) { + store.record({ + txHash: `tx${i}`, + signer: i % 2 === 0 ? "G_EVEN" : "G_ODD", + timestamp: `2026-01-${String(i).padStart(2, "0")}T00:00:00Z`, + status: "success", + }); + } + + // Query without filters returns at most maxEntries + const all = store.query(); + expect(all).toHaveLength(5); + expect(all.map((r) => r.txHash)).toEqual(["tx16", "tx17", "tx18", "tx19", "tx20"]); + + // Filtered queries also respect capped records + const evens = store.query({ signer: "G_EVEN" }); + expect(evens.map((r) => r.txHash)).toEqual(["tx16", "tx18", "tx20"]); + expect(evens.length).toBeLessThanOrEqual(5); + }); + + it("clear() resets entries and size back to 0", () => { + const store = new InMemorySigningHistoryStore(3); + store.record({ txHash: "tx1", signer: "G1", timestamp: "2026-01-01T00:00:00Z", status: "success" }); + store.record({ txHash: "tx2", signer: "G1", timestamp: "2026-01-02T00:00:00Z", status: "success" }); + + expect(store.size).toBe(2); + store.clear(); + expect(store.size).toBe(0); + expect(store.query()).toHaveLength(0); + }); + }); }); describe("wallet connection persistence and recovery", () => { diff --git a/src/wallet/index.ts b/src/wallet/index.ts index 272a5a75..4d7cc5ca 100644 --- a/src/wallet/index.ts +++ b/src/wallet/index.ts @@ -76,11 +76,13 @@ export { getSigningHistory, exportSigningHistory, InMemorySigningHistoryStore, + DEFAULT_SIGNING_HISTORY_MAX_ENTRIES, } from "./signingHistory"; export type { SigningRecord, SigningHistoryFilter, SigningHistoryStore, + InMemorySigningHistoryStoreOptions, } from "./signingHistory"; export type { CreateSigningChallengeOptions, diff --git a/src/wallet/signingHistory.ts b/src/wallet/signingHistory.ts index 7cf8e5f2..073db075 100644 --- a/src/wallet/signingHistory.ts +++ b/src/wallet/signingHistory.ts @@ -18,6 +18,22 @@ export interface SigningHistoryFilter { to?: string; } +/** + * Default maximum number of signing records retained by {@link InMemorySigningHistoryStore} (#743). + */ +export const DEFAULT_SIGNING_HISTORY_MAX_ENTRIES = 1000; + +/** + * Options for {@link InMemorySigningHistoryStore}. + */ +export interface InMemorySigningHistoryStoreOptions { + /** + * Maximum number of signing records to retain in memory (default: 1000). + * When capacity is reached, the oldest entries are evicted in FIFO order. + */ + maxEntries?: number; +} + export interface SigningHistoryStore { record(entry: SigningRecord): void; query(filter?: SigningHistoryFilter): SigningRecord[]; @@ -26,9 +42,38 @@ export interface SigningHistoryStore { export class InMemorySigningHistoryStore implements SigningHistoryStore { private entries: SigningRecord[] = []; + private readonly maxEntries: number; + + /** + * @param options - Maximum entries number or options object with `maxEntries`. Defaults to 1000. + */ + constructor(options?: number | InMemorySigningHistoryStoreOptions) { + const rawMax = typeof options === "number" ? options : options?.maxEntries; + this.maxEntries = + typeof rawMax === "number" && Number.isFinite(rawMax) && rawMax > 0 + ? Math.floor(rawMax) + : DEFAULT_SIGNING_HISTORY_MAX_ENTRIES; + } + + /** + * Return the configured maximum capacity of the store. + */ + getMaxEntries(): number { + return this.maxEntries; + } + + /** + * Return the current number of retained records. + */ + get size(): number { + return this.entries.length; + } record(entry: SigningRecord): void { this.entries.push({ ...entry }); + if (this.entries.length > this.maxEntries) { + this.entries.splice(0, this.entries.length - this.maxEntries); + } } query(filter?: SigningHistoryFilter): SigningRecord[] {