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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
},
"dependencies": {
"@fastify/cors": "^10.0.2",
"@fastify/helmet": "^13.0.1",
"@fastify/jwt": "^9.0.2",
"@fastify/rate-limit": "^10.2.0",
"@opentelemetry/api": "^1.9.1",
Expand Down
47 changes: 47 additions & 0 deletions src/cache/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ import { redis } from "../config/redis.js";
import { logger } from "../utils/logger.js";
import { Counter } from "prom-client";

// In-process map used to coalesce concurrent cache-miss fetches for the same
// key so only one call reaches the DB/source (thundering-herd protection).
// The promise is stored while the fetch is in flight; all waiters share it.
const inFlightFetches = new Map<string, Promise<unknown>>();

const DEFAULT_TTL = 60;

export const cacheHits = new Counter({
Expand Down Expand Up @@ -78,6 +83,48 @@ export async function cacheSet<T>(
}
}

/**
* #217: Cache-aside with thundering-herd protection.
*
* Returns the cached value when present. On a miss, exactly ONE concurrent
* caller executes `fetchFn`; all other callers for the same key await the
* same in-flight promise so the backing store receives only one request
* regardless of how many requests arrive simultaneously.
*
* @param namespace - Prometheus label and key segment.
* @param key - Full Redis key (built with cacheKey()).
* @param fetchFn - Async function that fetches the value from the source.
* @param ttl - Cache TTL in seconds (default: DEFAULT_TTL).
*/
export async function cacheGetOrSet<T>(
namespace: string,
key: string,
fetchFn: () => Promise<T>,
ttl: number = DEFAULT_TTL,
): Promise<T> {
const cached = await cacheGet<T>(namespace, key);
if (cached !== null) return cached;

// Another request for this key is already in flight — join it.
const existing = inFlightFetches.get(key);
if (existing) {
return existing as Promise<T>;
}

// We are the winner: fetch and populate the cache.
const fetching = fetchFn()
.then(async (value) => {
await cacheSet(key, value, ttl);
return value;
})
.finally(() => {
inFlightFetches.delete(key);
});

inFlightFetches.set(key, fetching);
return fetching;
}

/**
* Deletes precise keys safely. Avoids high-latency KEYS scanning in production.
*/
Expand Down
47 changes: 47 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { initTracing, shutdownTracing } from "./tracing.js";

import Fastify from "fastify";
import cors from "@fastify/cors";
import helmet from "@fastify/helmet";
import jwt from "@fastify/jwt";
import rateLimit from "@fastify/rate-limit";
import { sql } from "drizzle-orm";
Expand Down Expand Up @@ -70,6 +71,37 @@ async function buildApp() {
});

// ─── Plugins ────────────────────────────────────────────────────────────

// #220: OWASP security headers via @fastify/helmet.
// Content-Security-Policy is set to a restrictive baseline — the API only
// serves JSON so no scripts/styles are needed; adjust if a docs UI is added.
await app.register(helmet, {
contentSecurityPolicy: {
directives: {
defaultSrc: ["'none'"],
scriptSrc: ["'none'"],
objectSrc: ["'none'"],
frameAncestors: ["'none'"],
},
},
// X-Frame-Options: DENY (redundant with CSP frameAncestors but kept for
// older clients that don't support CSP).
frameguard: { action: "deny" },
// Strict-Transport-Security: 1 year, includeSubDomains, preload.
hsts: {
maxAge: 31_536_000,
includeSubDomains: true,
preload: true,
},
referrerPolicy: { policy: "no-referrer" },
// X-Content-Type-Options: nosniff (prevents MIME-sniffing attacks).
noSniff: true,
// X-XSS-Protection is legacy but still useful for older browsers.
xssFilter: true,
// Remove X-Powered-By to avoid fingerprinting.
hidePoweredBy: true,
});

// CSRF note: auth is via the `Authorization: Bearer` header, which is
// CSRF-safe (cross-site requests can't set custom headers). `credentials:
// true` only matters if auth ever moves to cookies — if it does, add a CSRF
Expand Down Expand Up @@ -182,7 +214,21 @@ async function start() {
logger.error({ error }, "Cache warmer initialization failed");
}

const SHUTDOWN_TIMEOUT_MS = 10_000;

const shutdown = async (signal: string) => {
// #216: Force-exit if graceful shutdown exceeds the deadline so the
// process doesn't hang indefinitely when DB or Redis is stuck.
const forceExit = setTimeout(() => {
logger.error(
{ timeoutMs: SHUTDOWN_TIMEOUT_MS },
"Graceful shutdown timed out — forcing exit",
);
process.exit(1);
}, SHUTDOWN_TIMEOUT_MS);
// Don't let this timer itself keep the event loop alive.
forceExit.unref();

logger.info({ signal }, "Received shutdown signal");
stopRetryProcessor();
stopIdempotencyCleanup();
Expand All @@ -193,6 +239,7 @@ async function start() {
await closeDatabase();
await closeRedis();
await shutdownTracing();
clearTimeout(forceExit);
logger.info("Server shut down cleanly");
process.exit(0);
};
Expand Down
29 changes: 26 additions & 3 deletions src/stellar/transactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ export async function invokeContract(
const seqNum = await sequenceCache.getNextSequence(keypair.publicKey());
const account = new StellarSdk.Account(keypair.publicKey(), seqNum);

const tx = new StellarSdk.TransactionBuilder(account, {
// Build an initial transaction for simulation; fee doesn't matter here.
const txForSim = new StellarSdk.TransactionBuilder(account, {
fee: StellarSdk.BASE_FEE,
networkPassphrase: getNetworkPassphrase(),
})
Expand All @@ -45,14 +46,36 @@ export async function invokeContract(
// simulation is wasted — assembleTransaction produces a new tx that
// must be signed separately)
const soroban = getSorobanServer();
const simResult = await soroban.simulateTransaction(tx);
const simResult = await soroban.simulateTransaction(txForSim);
if (StellarSdk.rpc.Api.isSimulationError(simResult)) {
logger.error({ error: simResult.error }, "Simulation failed");
throw new StellarError(`Simulation failed: ${simResult.error}`);
}

// #218: Compute the total fee from the simulation result.
// minResourceFee covers Soroban resource costs; BASE_FEE covers
// ledger inclusion. A 20 % buffer absorbs fee-market fluctuations
// between simulation and submission without over-paying significantly.
const resourceFee = BigInt(
(simResult as StellarSdk.rpc.Api.SimulateTransactionSuccessResponse)
.minResourceFee ?? "0",
);
const inclusionFee = BigInt(StellarSdk.BASE_FEE);
const totalFee = String(
inclusionFee + (resourceFee * 120n) / 100n,
);

// Rebuild the transaction with the computed fee before assembling.
const txWithFee = new StellarSdk.TransactionBuilder(account, {
fee: totalFee,
networkPassphrase: getNetworkPassphrase(),
})
.addOperation(contract.call(method, ...args))
.setTimeout(60)
.build();

// Prepare the transaction with the simulation results
const preparedTx = StellarSdk.rpc.assembleTransaction(tx, simResult).build();
const preparedTx = StellarSdk.rpc.assembleTransaction(txWithFee, simResult).build();
preparedTx.sign(keypair);

const result = await stellarClient.submitTransaction(preparedTx);
Expand Down
Loading