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
56 changes: 42 additions & 14 deletions src/client/LazyInitializer.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* LazyInitializer — single-promise initialization gate.
* LazyInitializer — single-promise initialization gate with configurable retry.
*
* Stores a factory function and a nullable Promise state. The first call to
* .get() invokes the factory and caches the resulting Promise. All subsequent
Expand All @@ -12,10 +12,28 @@
* Issue #479
*/

import { pbkdf2 } from "node:crypto";

// ---------------------------------------------------------------------------
// LazyInitializer<T>
// ---------------------------------------------------------------------------

/**
* Options for {@link LazyInitializer}.
*/
export interface LazyInitializerOptions {
/**
* Maximum number of retry attempts after a failed initialization.
* @default 3
*/
maxRetries?: number;
/**
* Delay between retry attempts in milliseconds.
* @default 1000
*/
retryDelayMs?: number;
}

/**
* Generic lazy initializer with single-flight coalescing and failure reset.
*
Expand All @@ -38,9 +56,13 @@ export class LazyInitializer<T> {
private readonly factory: () => Promise<T>;
private _promise: Promise<T> | null = null;
private _resolved = false;
private readonly maxRetries: number;
private readonly retryDelayMs: number;

constructor(factory: () => Promise<T>) {
constructor(factory: () => Promise<T>, options?: LazyInitializerOptions) {
this.factory = factory;
this.maxRetries = options?.maxRetries ?? 3;
this.retryDelayMs = options?.retryDelayMs ?? 1_000;
}

/**
Expand All @@ -51,25 +73,31 @@ export class LazyInitializer<T> {
* same Promise without calling the factory again.
* - If not yet started, calls the factory and caches the resulting Promise.
* - If the previous attempt failed, clears the cached Promise and retries.
* - Retries up to maxRetries times with retryDelayMs between attempts.
*/
get(): Promise<T> {
if (!this._promise) {
this._promise = this.factory().then(
(value) => {
this._resolved = true;
return value;
},
(err: unknown) => {
// Reset so the next call retries initialization.
this._promise = null;
this._resolved = false;
throw err;
},
);
this._promise = this._attempt(0);
}
return this._promise;
}

private async _attempt(attempt: number): Promise<T> {
try {
const value = await this.factory();
this._resolved = true;
return value;
} catch (err) {
if (attempt < this.maxRetries) {
await new Promise((resolve) => setTimeout(resolve, this.retryDelayMs));
return this._attempt(attempt + 1);
}
this._promise = null;
this._resolved = false;
throw err;
}
}

/**
* Returns true synchronously when the factory has completed successfully.
* Returns false when initialization is pending or hasn't started.
Expand Down
29 changes: 25 additions & 4 deletions src/client/SplitClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

import { rpc as SorobanRpc } from "@stellar/stellar-sdk";
import { LazyInitializer } from "./LazyInitializer.js";
import { RpcConnectionError } from "../errors.js";
import { RpcConnectionError, RequestTimeoutError } from "../errors.js";

// ---------------------------------------------------------------------------
// Public config
Expand All @@ -31,6 +31,8 @@ export interface SplitClientConfig {
networkPassphrase: string;
/** Deployed StellarSplit contract ID. */
contractId: string;
/** Per-request timeout in milliseconds. Defaults to 30 000. */
requestTimeoutMs?: number;
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -79,7 +81,7 @@ export class SplitClient {
*/
async getLedger(): Promise<number> {
const server = await this.ensureConnected();
const info = await server.getLatestLedger();
const info = await this._withTimeout(server.getLatestLedger(), "getLedger");
return info.sequence;
}

Expand All @@ -91,7 +93,7 @@ export class SplitClient {
tx: Parameters<SorobanRpc.Server["simulateTransaction"]>[0],
): Promise<ReturnType<SorobanRpc.Server["simulateTransaction"]>> {
const server = await this.ensureConnected();
return server.simulateTransaction(tx);
return this._withTimeout(server.simulateTransaction(tx), "simulateTransaction");
}

/**
Expand All @@ -102,7 +104,7 @@ export class SplitClient {
tx: Parameters<SorobanRpc.Server["sendTransaction"]>[0],
): Promise<ReturnType<SorobanRpc.Server["sendTransaction"]>> {
const server = await this.ensureConnected();
return server.sendTransaction(tx);
return this._withTimeout(server.sendTransaction(tx), "sendTransaction");
}

/**
Expand All @@ -124,6 +126,24 @@ export class SplitClient {
return this._lazy.get();
}

/**
* Wraps a promise with timeout enforcement. If the underlying SDK throws a
* timeout-related error, it is converted to RequestTimeoutError.
*/
private async _withTimeout<T>(promise: Promise<T>, method: string): Promise<T> {
const timeoutMs = this.config.requestTimeoutMs ?? 30_000;
if (timeoutMs <= 0) return promise;

try {
return await promise;
} catch (err: unknown) {
if (err instanceof Error && /timeout/i.test(err.message)) {
throw new RequestTimeoutError(method, timeoutMs);
}
throw err;
}
}

/**
* Factory that creates and validates the SorobanRpc.Server connection.
* Wraps construction errors in RpcConnectionError.
Expand All @@ -133,6 +153,7 @@ export class SplitClient {
try {
const server = new SorobanRpc.Server(rpcUrl, {
allowHttp: rpcUrl.startsWith("http://"),
timeout: this.config.requestTimeoutMs ?? 30_000,
});
return server;
} catch (err: unknown) {
Expand Down
66 changes: 61 additions & 5 deletions src/signing/adapters/EncryptedFileSigner.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
import { createCipheriv, createDecipheriv, pbkdf2, randomBytes } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { Keypair } from "@stellar/stellar-sdk";
import type { Signer } from "../signer.js";
Expand Down Expand Up @@ -69,6 +69,8 @@ export class EncryptedFileSigner implements Signer {
private readonly aesKey: Buffer;
/** Weak reference to the decrypted keypair — cleared by GC or clearCache(). */
private cachedKeypairRef: WeakRef<Keypair> | null = null;
/** Promise-based lock serialising key rotation and sign operations. */
private _rotationLock: Promise<void> = Promise.resolve();

constructor(filePath: string, options: EncryptedFileSignerOptions) {
this.filePath = filePath;
Expand All @@ -87,6 +89,39 @@ export class EncryptedFileSigner implements Signer {
this.cachedKeypairRef = null;
}

/**
* Rotates the encrypted signing key to a new file.
*
* The new file is loaded and validated before the in-memory state is
* replaced. A promise-based lock ensures that:
* - Signing operations already in flight complete with the old key.
* - Only one rotation is in progress at a time.
* - All new signing operations after rotation use the new key.
*
* @param newKeyFilePath Path to the new encrypted PEM key file.
* @param passphrase Passphrase used to derive the AES-256 decryption key.
*/
async rotateKey(newKeyFilePath: string, passphrase: string): Promise<void> {
const previousLock = this._rotationLock;

let resolveRotation!: () => void;
this._rotationLock = new Promise<void>((resolve) => {
resolveRotation = resolve;
});

try {
await previousLock;
const newAesKey = await this._deriveKey(passphrase);
const newKeypair = await this._loadKeypairFromFile(newKeyFilePath, newAesKey);

this.filePath = newKeyFilePath;
this.aesKey = newAesKey;
this.cachedKeypairRef = new WeakRef(newKeypair);
} finally {
resolveRotation();
}
}

async sign(txHash: Buffer): Promise<Buffer> {
const keypair = await this._getKeypair();
return Buffer.from(keypair.sign(txHash));
Expand All @@ -95,17 +130,22 @@ export class EncryptedFileSigner implements Signer {
private async _getKeypair(): Promise<Keypair> {
const cached = this.cachedKeypairRef?.deref();
if (cached) return cached;
// Re-read + decrypt on first use / after GC. The keypair is kept alive
// for the duration of this call even though only a WeakRef is stored.
await this._rotationLock;
const cachedAgain = this.cachedKeypairRef?.deref();
if (cachedAgain) return cachedAgain;
const keypair = await this._loadKeypair();
this.cachedKeypairRef = new WeakRef(keypair);
return keypair;
}

private async _loadKeypair(): Promise<Keypair> {
const content = await readFile(this.filePath, "utf8");
return this._loadKeypairFromFile(this.filePath, this.aesKey);
}

private async _loadKeypairFromFile(filePath: string, aesKey: Buffer): Promise<Keypair> {
const content = await readFile(filePath, "utf8");
const { iv, authTag, ciphertext } = parseEncryptedPayload(content);
const decipher = createDecipheriv("aes-256-gcm", this.aesKey, iv);
const decipher = createDecipheriv("aes-256-gcm", aesKey, iv);
decipher.setAuthTag(authTag);
const plaintext = Buffer.concat([
decipher.update(ciphertext),
Expand All @@ -114,6 +154,22 @@ export class EncryptedFileSigner implements Signer {
const secret = extractSecretFromPem(plaintext.toString("utf8"));
return Keypair.fromSecret(secret);
}

private async _deriveKey(passphrase: string): Promise<Buffer> {
return new Promise<Buffer>((resolve, reject) => {
pbkdf2(
passphrase,
"split-sdk-rotation-salt",
100_000,
32,
"sha256",
(err, derivedKey) => {
if (err) reject(err);
else resolve(Buffer.from(derivedKey));
},
);
});
}
}

function parseEncryptedPayload(content: string): {
Expand Down
Loading