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
26 changes: 21 additions & 5 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -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',
}
}
);
];
2 changes: 1 addition & 1 deletion src/contract/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Function> = {};
readonly methods: Record<string, (...args: never[]) => Promise<unknown>> = {};

constructor(
client: TrustFlowClient,
Expand Down
70 changes: 63 additions & 7 deletions src/escrow/multisig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -31,8 +48,15 @@ export class MultiSigEscrowClient {
/** In-memory store of pending multi-sig operations, keyed by operationId. */
private readonly operations = new Map<string, MultiSigOperation>();
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
Expand Down Expand Up @@ -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' };
}

Expand Down Expand Up @@ -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) };
Expand All @@ -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' };
}

Expand All @@ -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: {
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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));
Expand Down
7 changes: 7 additions & 0 deletions src/types/multisig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
107 changes: 107 additions & 0 deletions tests/multisig-eviction.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});