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[] {