Skip to content
Merged
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
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) ──────────────────
Expand Down
80 changes: 80 additions & 0 deletions src/tests/wallet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
InMemorySigningHistoryStore,
getSigningHistory,
exportSigningHistory,
DEFAULT_SIGNING_HISTORY_MAX_ENTRIES,
type SigningRecord,
} from "../wallet/signingHistory";
import {
Expand Down Expand Up @@ -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", () => {
Expand Down
2 changes: 2 additions & 0 deletions src/wallet/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
45 changes: 45 additions & 0 deletions src/wallet/signingHistory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -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[] {
Expand Down
Loading