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
59 changes: 59 additions & 0 deletions src/errors/errorEnvelopePolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export const PUBLIC_ERROR_CODES = [
'NETWORK_MISMATCH',
'SOROBAN_RPC_TIMEOUT',
'SOROBAN_RPC_ERROR',
'SIMULATION_FAILED',
'BILLING_DEDUCTION_FAILED',
'BILLING_REQUEST_NOT_FOUND',
'DEVELOPER_NOT_FOUND',
Expand Down Expand Up @@ -108,6 +109,60 @@ export function safeValidationDetails(value: unknown): ValidationErrorDetail[] |
return details.length > 0 ? details : undefined;
}

/**
* Simulation diagnostics that are safe to publish to clients.
*
* This deliberately mirrors ONLY the redacted summary produced by
* `lib/simulationDiagnostics.ts` (`errorCode`, `errorMessage`, `eventCount`,
* `footprintPresent`). Raw simulation payloads carry account addresses,
* balances, XDR and signatures, so anything outside this whitelist is dropped
* rather than forwarded.
*/
export interface RedactedSimulationDetailsSummary {
errorCode?: string | number;
errorMessage?: string;
eventCount?: number;
footprintPresent?: boolean;
}

/**
* Re-validate an already-redacted simulation summary and bound its fields.
*
* Used as defence in depth: even if a caller hands the envelope a raw or
* hand-crafted object, only the four whitelisted fields with the expected
* primitive types can reach the response body. Returns `undefined` when
* nothing survives, so the envelope omits the key entirely.
*/
export function safeSimulationDetails(value: unknown): RedactedSimulationDetailsSummary | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;

const candidate = value as Record<string, unknown>;
const summary: RedactedSimulationDetailsSummary = {};

const errorCode = candidate.errorCode;
if (typeof errorCode === 'string' && errorCode.trim() !== '') {
summary.errorCode = errorCode.slice(0, 200);
} else if (typeof errorCode === 'number' && Number.isFinite(errorCode)) {
summary.errorCode = errorCode;
}

const errorMessage = candidate.errorMessage;
if (typeof errorMessage === 'string' && errorMessage.trim() !== '') {
summary.errorMessage = errorMessage.slice(0, 500);
}

const eventCount = candidate.eventCount;
if (typeof eventCount === 'number' && Number.isFinite(eventCount) && eventCount >= 0) {
summary.eventCount = Math.floor(eventCount);
}

if (typeof candidate.footprintPresent === 'boolean') {
summary.footprintPresent = candidate.footprintPresent;
}

return Object.keys(summary).length > 0 ? summary : undefined;
}

export function boundedRetryAfterMs(value: unknown): number | undefined {
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return undefined;
return Math.min(Math.floor(value), 86_400_000);
Expand All @@ -119,6 +174,7 @@ export interface NormalizedError {
message: string;
details?: ValidationErrorDetail[];
retryAfterMs?: number;
simulationDetails?: RedactedSimulationDetailsSummary;
}

export function normalizeError(input: {
Expand All @@ -127,6 +183,7 @@ export function normalizeError(input: {
message?: unknown;
details?: unknown;
retryAfterMs?: unknown;
simulationDetails?: unknown;
trusted: boolean;
development?: boolean;
}): NormalizedError {
Expand All @@ -137,7 +194,9 @@ export function normalizeError(input: {
};
const details = safeValidationDetails(input.details);
const retryAfterMs = boundedRetryAfterMs(input.retryAfterMs);
const simulationDetails = safeSimulationDetails(input.simulationDetails);
if (details) normalized.details = details;
if (retryAfterMs !== undefined) normalized.retryAfterMs = retryAfterMs;
if (simulationDetails) normalized.simulationDetails = simulationDetails;
return normalized;
}
55 changes: 55 additions & 0 deletions src/errors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

import type { ErrorCode as ErrorCodeType } from "./codes.js";
import { redactSimulationDetails } from "../lib/simulationDiagnostics.js";

// Re-export ErrorCode from the generated codes module
export { ErrorCode, isErrorCode, type ErrorCode as ErrorCodeType } from "./codes.js";
Expand Down Expand Up @@ -89,6 +90,60 @@ export class BadGatewayError extends AppError {
}
}

/**
* A Soroban simulation (pre-flight) returned a failure response.
*
* This is a `BadGatewayError` (502) carrying the canonical
* `SIMULATION_FAILED` code plus a *redacted* summary of the RPC diagnostics,
* so it travels through the global error handler and therefore gets the
* standard error envelope and `requestId` like every other failure.
*
* Redaction happens in the constructor rather than at the call site: raw
* simulation payloads contain account addresses, balances, XDR and
* signatures, so no caller can accidentally publish them by constructing
* this error with unredacted input.
*/
export class SimulationFailedError extends BadGatewayError {
/**
* Marker used instead of `instanceof`.
*
* `AppError` re-points `this` at `AppError.prototype`, which severs the
* prototype chain of every subclass, so `err instanceof
* SimulationFailedError` is always false. `isAppError` already works around
* this with a flag; this mirrors that pattern.
*/
public readonly isSimulationFailedError = true;

constructor(
message: string = "Soroban simulation failed",
simulationDetails?: unknown,
) {
super(
message,
"SIMULATION_FAILED",
simulationDetails === undefined
? undefined
: redactSimulationDetails(simulationDetails),
);
this.name = "SimulationFailedError";
}
}

/**
* Type guard for {@link SimulationFailedError}.
*
* Used by the error handler so simulation details are only ever read off an
* error this codebase constructed (and therefore only ever read in a
* redacted form).
*/
export function isSimulationFailedError(err: unknown): err is SimulationFailedError {
return (
!!err &&
typeof err === "object" &&
(err as Record<string, unknown>).isSimulationFailedError === true
);
}

export class ServiceUnavailableError extends AppError {
constructor(message: string = "Service unavailable", code?: ErrorCodeType) {
super(message, 503, code ?? "SERVICE_UNAVAILABLE");
Expand Down
30 changes: 29 additions & 1 deletion src/middleware/envelope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { Request, Response, NextFunction } from 'express';
import { z, ZodSchema, ZodError } from 'zod';
import type { ValidationErrorDetail } from './validate.js';
import { InternalServerError } from '../errors/index.js';
import { safeSimulationDetails } from '../errors/errorEnvelopePolicy.js';
import type { RedactedSimulationDetailsSummary } from '../errors/errorEnvelopePolicy.js';
import { logger } from '../logger.js';

const SKIP_CONTENT_TYPES = [
Expand All @@ -26,6 +28,19 @@ export const successEnvelopeSchema = z.object({
timestamp: z.string().datetime(),
});

/**
* Optional, additive member of `error` describing a failed Soroban
* simulation. Only the redacted summary fields are representable here, so a
* client can rely on `error.simulationDetails` never containing addresses,
* balances, XDR or signatures.
*/
export const simulationDetailsSchema = z.object({
errorCode: z.union([z.string(), z.number()]).optional(),
errorMessage: z.string().optional(),
eventCount: z.number().optional(),
footprintPresent: z.boolean().optional(),
});

export const errorEnvelopeSchema = z.object({
success: z.literal(false),
error: z.object({
Expand All @@ -37,6 +52,7 @@ export const errorEnvelopeSchema = z.object({
code: z.string(),
})).optional(),
retryAfterMs: z.number().optional(),
simulationDetails: simulationDetailsSchema.optional(),
}),
requestId: z.string(),
timestamp: z.string().datetime(),
Expand Down Expand Up @@ -83,6 +99,7 @@ export function buildErrorEnvelope(
requestId: string,
details?: ValidationErrorDetail[],
retryAfterMs?: number,
simulationDetails?: RedactedSimulationDetailsSummary,
): ErrorEnvelope {
const envelope: ErrorEnvelope = {
success: false,
Expand All @@ -99,6 +116,10 @@ export function buildErrorEnvelope(
if (retryAfterMs !== undefined) {
envelope.error.retryAfterMs = retryAfterMs;
}
const safeDetails = safeSimulationDetails(simulationDetails);
if (safeDetails) {
envelope.error.simulationDetails = safeDetails;
}
return envelope;
}

Expand Down Expand Up @@ -135,6 +156,7 @@ export function envelopeMiddleware(req: Request, res: Response, next: NextFuncti
let message = 'Request failed';
let details: ValidationErrorDetail[] | undefined;
let retryAfterMs: number | undefined;
let simulationDetails: RedactedSimulationDetailsSummary | undefined;

if (body !== null && typeof body === 'object' && !Array.isArray(body)) {
const bodyObj = body as Record<string, unknown>;
Expand All @@ -154,9 +176,15 @@ export function envelopeMiddleware(req: Request, res: Response, next: NextFuncti
if (typeof bodyObj.retryAfterMs === 'number') {
retryAfterMs = bodyObj.retryAfterMs;
}
// Preserve a redacted simulation summary when a route (or an upstream
// proxy) emits the canonical SIMULATION_FAILED shape directly. The
// whitelist keeps raw diagnostics out of the envelope.
if (bodyObj.simulationDetails !== undefined) {
simulationDetails = safeSimulationDetails(bodyObj.simulationDetails);
}
}

return buildErrorEnvelope(code, message, requestId, details, retryAfterMs);
return buildErrorEnvelope(code, message, requestId, details, retryAfterMs, simulationDetails);
}

let data = body;
Expand Down
22 changes: 19 additions & 3 deletions src/middleware/errorHandler.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import type { Request, Response, NextFunction } from 'express';
import { isAppError } from '../errors/index.js';import { logger } from '../logger.js';
import { isAppError, isSimulationFailedError } from '../errors/index.js';
import { logger } from '../logger.js';
import type { ValidationErrorDetail } from './validate.js';
import { ValidationError } from './validate.js';import { buildErrorEnvelope } from './envelope.js';import type { ErrorEnvelope } from '../types/ResponseEnvelope.js';import { normalizeError } from '../errors/errorEnvelopePolicy.js';
import { ValidationError } from './validate.js';
import { buildErrorEnvelope } from './envelope.js';
import type { ErrorEnvelope } from '../types/ResponseEnvelope.js';
import { normalizeError } from '../errors/errorEnvelopePolicy.js';

const isProduction = process.env.NODE_ENV === "production";

Expand Down Expand Up @@ -51,15 +55,27 @@ export function errorHandler(
: "Internal server error";

const requestId = req.id || "unknown";
// Simulation details are only read off the SimulationFailedError this
// codebase throws; they were redacted in its constructor and are re-checked
// against the whitelist by normalizeError/buildErrorEnvelope.
const simulationDetails = isSimulationFailedError(err) ? err.simulationDetails : undefined;
const normalized = normalizeError({
statusCode,
code: isAppError(err) ? err.code : undefined,
message: rawMessage,
details: extractValidationDetails(err),
simulationDetails,
trusted: isAppError(err),
development: process.env.NODE_ENV === 'development',
});
const body = buildErrorEnvelope(normalized.code, normalized.message, requestId, normalized.details, normalized.retryAfterMs);
const body = buildErrorEnvelope(
normalized.code,
normalized.message,
requestId,
normalized.details,
normalized.retryAfterMs,
normalized.simulationDetails,
);

if (!res.headersSent) {
res.status(statusCode).json(body);
Expand Down
Loading
Loading