From ca00e70e7826e46a990006e7322f69170b59c1d0 Mon Sep 17 00:00:00 2001 From: connelblaze Date: Mon, 31 Aug 2026 10:10:56 +0100 Subject: [PATCH 1/3] Automatic Eviction of Terminal MultiSig Operations --- src/escrow/multisig.ts | 70 ++++++++++++++++++--- src/types/multisig.ts | 7 +++ tests/multisig-eviction.test.ts | 107 ++++++++++++++++++++++++++++++++ 3 files changed, 177 insertions(+), 7 deletions(-) create mode 100644 tests/multisig-eviction.test.ts diff --git a/src/escrow/multisig.ts b/src/escrow/multisig.ts index d180435..69b6b7e 100644 --- a/src/escrow/multisig.ts +++ b/src/escrow/multisig.ts @@ -18,6 +18,23 @@ import type { import { MULTISIG_SNAPSHOT_VERSION } from '../types/multisig'; import { submitTransaction } from '../stellar/transaction'; +/** + * Default length of time (ms) a terminal-status operation (`submitted` or + * `expired`) is retained before it becomes eligible for automatic eviction. + */ +export const DEFAULT_MULTISIG_RETENTION_MS = 5 * 60 * 1000; + +/** + * Constructor options for {@link MultiSigEscrowClient}. + */ +export interface MultiSigEscrowClientOptions { + /** + * How long (ms) a terminal-status operation is retained before it is evicted + * from the in-memory store. Defaults to {@link DEFAULT_MULTISIG_RETENTION_MS}. + */ + retentionMs?: number; +} + /** * Client for collecting M-of-N signatures on shared backend Escrow operations. * @@ -31,8 +48,15 @@ export class MultiSigEscrowClient { /** In-memory store of pending multi-sig operations, keyed by operationId. */ private readonly operations = new Map(); private _opCounter = 0; - - constructor(private readonly config: ContractConfig) {} + /** Retention window for terminal-status operations before eviction. */ + private readonly retentionMs: number; + + constructor( + private readonly config: ContractConfig, + options?: MultiSigEscrowClientOptions, + ) { + this.retentionMs = options?.retentionMs ?? DEFAULT_MULTISIG_RETENTION_MS; + } // --------------------------------------------------------------------------- // Public API @@ -91,7 +115,7 @@ export class MultiSigEscrowClient { } if (this._isExpired(operation)) { - operation.status = 'expired'; + this._markTerminal(operation, 'expired'); return { ok: false, error: 'Operation has expired' }; } @@ -140,7 +164,7 @@ export class MultiSigEscrowClient { } if (this._isExpired(operation) && operation.status === 'pending') { - operation.status = 'expired'; + this._markTerminal(operation, 'expired'); } return { ok: true, data: this._buildStatus(operation) }; @@ -162,7 +186,7 @@ export class MultiSigEscrowClient { } if (this._isExpired(operation)) { - operation.status = 'expired'; + this._markTerminal(operation, 'expired'); return { ok: false, error: 'Operation has expired' }; } @@ -181,7 +205,7 @@ export class MultiSigEscrowClient { try { const submitted = await submitTransaction(assembledResult.data.xdr, horizonUrl); - operation.status = 'submitted'; + this._markTerminal(operation, 'submitted'); return { ok: true, data: { @@ -225,11 +249,33 @@ export class MultiSigEscrowClient { } /** - * Returns all operations associated with a given escrow, regardless of status. + * Evicts terminal-status operations (`submitted` or `expired`) that have been + * retained past the configured retention window, preventing the internal + * operations `Map` from growing without bound in long-lived processes. + * + * Calling this also triggers an eviction sweep on every {@link listOperations} + * call, so listed results only ever reflect retained (non-evicted) operations. + */ + prune(): void { + const cutoff = Date.now() - this.retentionMs; + for (const [operationId, op] of this.operations) { + const terminal = op.status === 'submitted' || op.status === 'expired'; + if (terminal && op.terminalAt !== undefined && op.terminalAt <= cutoff) { + this.operations.delete(operationId); + } + } + } + + /** + * Returns all retained operations associated with a given escrow, regardless + * of status. Terminal operations that have been evicted by {@link prune} (past + * the retention window) are excluded, so this reflects only retained + * (non-evicted) operations. * * @param escrowId - Escrow identifier */ listOperations(escrowId: string): MultiSigOperation[] { + this.prune(); return Array.from(this.operations.values()).filter((op) => op.escrowId === escrowId); } @@ -467,6 +513,16 @@ export class MultiSigEscrowClient { return operation.expiresAt !== undefined && Date.now() > operation.expiresAt; } + /** + * Transitions an operation to a terminal status (`expired` or `submitted`) + * and records when it reached that state, so {@link prune} can evict it once + * the retention window elapses. + */ + private _markTerminal(operation: MultiSigOperation, status: 'expired' | 'submitted'): void { + operation.status = status; + operation.terminalAt = Date.now(); + } + private _buildStatus(operation: MultiSigOperation): MultiSigStatus { const signersSigned = operation.collectedSignatures.map((s) => s.signerAddress); const signersRemaining = operation.signers.filter((s) => !signersSigned.includes(s)); diff --git a/src/types/multisig.ts b/src/types/multisig.ts index 5e05e97..d5b82af 100644 --- a/src/types/multisig.ts +++ b/src/types/multisig.ts @@ -43,6 +43,13 @@ export interface MultiSigOperation { status: MultiSigOperationStatus; createdAt: number; expiresAt?: number; + /** + * UNIX timestamp (ms) at which the operation reached a terminal status + * (`submitted` or `expired`). Used by `MultiSigEscrowClient.prune` to decide + * when a completed operation may be evicted from memory. Absent while the + * operation is still `pending`/`ready`. + */ + terminalAt?: number; } /** Parameters for initiating a new multi-sig operation */ diff --git a/tests/multisig-eviction.test.ts b/tests/multisig-eviction.test.ts new file mode 100644 index 0000000..2552231 --- /dev/null +++ b/tests/multisig-eviction.test.ts @@ -0,0 +1,107 @@ +import { MultiSigEscrowClient } from '../src/escrow/multisig'; + +describe('MultiSigEscrowClient automatic eviction', () => { + const NETWORK = 'Test SDF Network ; September 2015'; + const XDR = 'AAAAAGXQAAAAAAAAAAA='; + + function makeClient(retentionMs = 1000): MultiSigEscrowClient { + return new MultiSigEscrowClient( + { networkPassphrase: NETWORK } as any, + { retentionMs }, + ); + } + + function initOperation(client: MultiSigEscrowClient, escrowId = 'escrow-1') { + const result = client.initMultiSigOperation({ + escrowId, + signers: ['GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'], + threshold: 1, + operationType: 'release', + unsignedXdr: XDR, + networkPassphrase: NETWORK, + }); + expect(result.ok).toBe(true); + return (result as { ok: true; data: { operationId: string } }).data.operationId; + } + + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('evicts an expired operation once it passes the retention window', () => { + const client = makeClient(1000); + const expiresAt = Date.now() + 1000; + const result = client.initMultiSigOperation({ + escrowId: 'escrow-1', + signers: ['GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'], + threshold: 1, + operationType: 'release', + unsignedXdr: XDR, + networkPassphrase: NETWORK, + expiresAt, + }); + expect(result.ok).toBe(true); + const operationId = (result as { ok: true; data: { operationId: string } }).data.operationId; + + jest.setSystemTime(expiresAt + 1); + const status = client.getMultiSigStatus(operationId); + expect(status.ok).toBe(true); + expect((status as { ok: true; data: { status: string } }).data.status).toBe('expired'); + + // Still listed while within the retention window. + expect(client.listOperations('escrow-1')).toHaveLength(1); + + // After the retention window elapses, the operation is evicted. + jest.setSystemTime(expiresAt + 1 + 1000 + 1); + expect(client.listOperations('escrow-1')).toHaveLength(0); + }); + + it('evicts a submitted operation once it passes the retention window', () => { + const client = makeClient(1000); + const operationId = initOperation(client); + + // Simulate a completed operation reaching terminal status. + const op = (client as any).operations.get(operationId); + op.status = 'submitted'; + op.terminalAt = Date.now(); + + expect(client.listOperations('escrow-1')).toHaveLength(1); + jest.setSystemTime(Date.now() + 1000 + 1); + expect(client.listOperations('escrow-1')).toHaveLength(0); + }); + + it('does not evict pending or ready operations regardless of elapsed time', () => { + const client = makeClient(1000); + const operationId = initOperation(client); + + jest.setSystemTime(Date.now() + 60 * 60 * 1000); + expect(client.listOperations('escrow-1')).toHaveLength(1); + + const op = (client as any).operations.get(operationId); + op.status = 'ready'; + op.terminalAt = undefined; + jest.setSystemTime(Date.now() + 60 * 60 * 1000); + expect(client.listOperations('escrow-1')).toHaveLength(1); + }); + + it('expose a prune() method that evicts terminal operations explicitly', () => { + const client = makeClient(1000); + initOperation(client); + + // Mark the operation as expired at the current time. + const op = (client as any).operations.get( + Array.from((client as any).operations.keys())[0], + ); + op.status = 'expired'; + op.terminalAt = Date.now(); + + expect((client as any).operations.size).toBe(1); + jest.setSystemTime(Date.now() + 1000 + 1); + client.prune(); + expect((client as any).operations.size).toBe(0); + }); +}); From ccde71393437578ca459987e92472e412d720016 Mon Sep 17 00:00:00 2001 From: connelblaze Date: Mon, 31 Aug 2026 10:33:37 +0100 Subject: [PATCH 2/3] fix: load ESLint --- eslint.config.mjs | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index af7bccd..fddd8a3 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,15 +1,31 @@ import eslint from '@eslint/js'; -import tseslint from 'typescript-eslint'; +import tsPlugin from '@typescript-eslint/eslint-plugin'; +import tsParser from '@typescript-eslint/parser'; +import globals from 'globals'; -export default tseslint.config( +export default [ eslint.configs.recommended, - ...tseslint.configs.recommended, { + files: ['src/**/*.ts'], + languageOptions: { + parser: tsParser, + // The SDK supports both browser wallet integrations and Node consumers. + globals: { + ...globals.browser, + ...globals.node, + }, + }, + plugins: { + '@typescript-eslint': tsPlugin, + }, rules: { + ...tsPlugin.configs.recommended.rules, // Ported rules from legacy .eslintrc.json configuration mapping 'no-throw-literal': 'error', 'no-param-reassign': 'warn', - '@typescript-eslint/strict-boolean-expressions': 'warn' + // These type-focused checks remain available in `npm run lint:strict`. + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': 'off', } } -); +]; From a762065ce8c5b804384d1153006a4e8e901b4ed5 Mon Sep 17 00:00:00 2001 From: connelblaze Date: Mon, 31 Aug 2026 10:51:10 +0100 Subject: [PATCH 3/3] fix: load ESLint --- src/contract/bindings.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/contract/bindings.ts b/src/contract/bindings.ts index d3e61e8..acf4cf8 100644 --- a/src/contract/bindings.ts +++ b/src/contract/bindings.ts @@ -16,7 +16,7 @@ import { TrustFlowError } from '../errors'; */ export class SorobanContractClient extends AbstractContractClient { /** Map of dynamically generated contract methods bound to this client instance */ - readonly methods: Record = {}; + readonly methods: Record Promise> = {}; constructor( client: TrustFlowClient,