diff --git a/docs/payouts-stellar-result-codes-retry-storms.md b/docs/payouts-stellar-result-codes-retry-storms.md index 19905ae3..d4711652 100644 --- a/docs/payouts-stellar-result-codes-retry-storms.md +++ b/docs/payouts-stellar-result-codes-retry-storms.md @@ -16,6 +16,7 @@ Extended the existing `classifyStellarRPCFailure` classifier with: - `STELLAR_TX_RESULT_CODES` — allowlist of all Horizon transaction-level result codes (`tx_bad_seq`, `tx_insufficient_fee`, `tx_bad_auth`, etc.) - `STELLAR_OP_RESULT_CODES` — allowlist of all Horizon operation-level result codes (`op_no_destination`, `op_underfunded`, `op_no_trust`, etc.) - Horizon `extras.result_codes` envelope parsing — op-level codes take precedence over tx-level for actionability +- Sanitized `originalError` payloads — raw Horizon/upstream `message` strings are replaced with `UPSTREAM_MESSAGE_REDACTED` - `isStellarRPCRetryable(cls)` — returns `true` only for `TIMEOUT` and `UPSTREAM_ERROR`; all protocol errors (`TX_RESULT_CODE`, `OP_RESULT_CODE`) and auth/rate errors are non-retryable ### `src/routes/payouts.test.ts` @@ -94,10 +95,12 @@ anything else → UNKNOWN 1. **No raw upstream strings in client JSON.** `classifyStellarRPCFailure` returns only an enum value. The `extras.envelope_xdr`, `result_xdr`, and raw error messages are never forwarded to callers. -2. **Investor data isolation.** `listPayouts` enforces `role === 'investor'` and scopes the repo query to `req.user.id`. Other roles receive 403 before any DB call is made. +2. **Sanitized classified failures stay safe in logs.** `failure.originalError` preserves only a narrow diagnostic shape (`status`, `code`, `result_xdr`, redacted `message`) so downstream logging does not accidentally leak Horizon text. -3. **No retry amplification.** The `listPayouts` handler makes exactly one repo call per request. Retry policy is the responsibility of the caller (job queue, middleware), not the HTTP handler. This prevents a single slow request from multiplying load on Horizon during an outage. +3. **Investor data isolation.** `listPayouts` enforces `role === 'investor'` and scopes the repo query to `req.user.id`. Other roles receive 403 before any DB call is made. -4. **Retry budget is bounded.** `DistributionEngine.withRetry` loops at most `maxRetries` times. Tests assert `callCount === maxRetries` to confirm no infinite loop is possible. +4. **No retry amplification.** The `listPayouts` handler makes exactly one repo call per request. Retry policy is the responsibility of the caller (job queue, middleware), not the HTTP handler. This prevents a single slow request from multiplying load on Horizon during an outage. -5. **Protocol errors are not retried.** `TX_RESULT_CODE` and `OP_RESULT_CODE` failures (e.g. `tx_bad_seq`, `op_underfunded`) indicate the transaction itself is malformed. Retrying without fixing the transaction wastes Horizon quota and can trigger rate limiting. +5. **Retry budget is bounded.** `DistributionEngine.withRetry` loops at most `maxRetries` times. Tests assert `callCount === maxRetries` to confirm no infinite loop is possible. + +6. **Protocol errors are not retried.** `TX_RESULT_CODE` and `OP_RESULT_CODE` failures (e.g. `tx_bad_seq`, `op_underfunded`) indicate the transaction itself is malformed. Retrying without fixing the transaction wastes Horizon quota and can trigger rate limiting. diff --git a/src/__tests__/stellarRpcFailure.integration.test.ts b/src/__tests__/stellarRpcFailure.integration.test.ts index c80ddf76..28374b73 100644 --- a/src/__tests__/stellarRpcFailure.integration.test.ts +++ b/src/__tests__/stellarRpcFailure.integration.test.ts @@ -51,6 +51,7 @@ jest.mock('@stellar/stellar-sdk', () => { jest.mock('../lib/logger', () => ({ globalLogger: { + child: jest.fn().mockReturnThis(), warn: jest.fn(), info: jest.fn(), error: jest.fn(), @@ -514,9 +515,10 @@ describe('Stellar RPC Failure Integration Tests', () => { // Verify error message is sanitized but not the long message itself expect(result.message).toBe('Stellar network temporarily unavailable'); - // Verify long message is handled in logs without causing issues + // Verify logs keep a stable shape without exposing the upstream string const logCall = (logger.warn as jest.Mock).mock.calls[0]; - expect(logCall[1].originalError.message).toBe(longMessage); + expect(logCall[1].originalError.message).toBe('UPSTREAM_MESSAGE_REDACTED'); + expect(JSON.stringify(logCall[1].originalError)).not.toContain(longMessage); }); }); }); diff --git a/src/lib/stellarRpcFailure.test.ts b/src/lib/stellarRpcFailure.test.ts index 7865365b..291bb150 100644 --- a/src/lib/stellarRpcFailure.test.ts +++ b/src/lib/stellarRpcFailure.test.ts @@ -6,86 +6,334 @@ import { StellarRPCFailureClass, } from "./stellarRpcFailure"; -describe('classifyStellarRPCFailure', () => { - const mockContext = { operation: 'test' }; +describe("classifyStellarRPCFailure", () => { + const context = { operation: "submit_payment" }; - it('classifies timeout-shaped failures', () => { - const failure = classifyStellarRPCFailure(new Error('upstream timeout while reading horizon'), mockContext); - expect(failure.class).toBe(StellarRPCFailureClass.TIMEOUT); - expect(failure.shouldRetry).toBe(true); + function expectRedactedMessage(result: ReturnType) { + expect(result.originalError).toEqual( + expect.objectContaining({ message: "UPSTREAM_MESSAGE_REDACTED" }), + ); + expect(JSON.stringify(result.originalError)).not.toContain("secret"); + expect(JSON.stringify(result.originalError)).not.toContain("tx_bad_seq"); + expect(JSON.stringify(result.originalError)).not.toContain("op_underfunded"); + } + + it("classifies Horizon transaction result codes as TX_RESULT_CODE", () => { + const fixture = { + status: 400, + message: "secret upstream message tx_bad_seq", + extras: { + result_codes: { + transaction: "tx_bad_seq", + }, + }, + }; + + const result = classifyStellarRPCFailure(fixture, context); + + expect(result.class).toBe(StellarRPCFailureClass.TX_RESULT_CODE); + expect(result.shouldRetry).toBe(false); + expect(result.originalError).toEqual({ + status: 400, + message: "UPSTREAM_MESSAGE_REDACTED", + }); }); - it('classifies upstream status failures', () => { - expect(classifyStellarRPCFailure({ status: 429 }, mockContext).class).toBe( - StellarRPCFailureClass.RATE_LIMIT, + it("classifies tx_insufficient_fee as non-retryable TX_RESULT_CODE", () => { + const result = classifyStellarRPCFailure( + { + status: 400, + message: "upstream says tx_insufficient_fee", + extras: { + result_codes: { + transaction: "tx_insufficient_fee", + operations: [], + }, + }, + }, + context, ); - expect(classifyStellarRPCFailure({ status: 401 }, mockContext).class).toBe( - StellarRPCFailureClass.UNAUTHORIZED, + + expect(result.class).toBe(StellarRPCFailureClass.TX_RESULT_CODE); + expect(result.shouldRetry).toBe(false); + expectRedactedMessage(result); + }); + + it("classifies Horizon operation result codes as OP_RESULT_CODE", () => { + const result = classifyStellarRPCFailure( + { + status: 400, + message: "upstream says op_no_destination", + extras: { + result_codes: { + transaction: "tx_failed", + operations: ["op_no_destination"], + }, + }, + }, + context, ); - expect(classifyStellarRPCFailure({ status: 503 }, mockContext).class).toBe( - StellarRPCFailureClass.UPSTREAM_ERROR, + + expect(result.class).toBe(StellarRPCFailureClass.OP_RESULT_CODE); + expect(result.shouldRetry).toBe(false); + expectRedactedMessage(result); + }); + + it("classifies op_underfunded as non-retryable OP_RESULT_CODE", () => { + const result = classifyStellarRPCFailure( + { + status: 400, + code: "HORIZON_PROTOCOL_ERROR", + message: "upstream says op_underfunded", + extras: { + result_codes: { + transaction: "tx_failed", + operations: ["op_underfunded"], + }, + }, + }, + context, ); - expect(result.class).toBe(StellarRPCFailureClass.MALFORMED_RESPONSE); + + expect(result.class).toBe(StellarRPCFailureClass.OP_RESULT_CODE); + expect(result.shouldRetry).toBe(false); + expectRedactedMessage(result); + }); + + it("prefers operation result codes over transaction result codes when both are present", () => { + const result = classifyStellarRPCFailure( + { + status: 400, + extras: { + result_codes: { + transaction: "tx_bad_seq", + operations: ["op_no_destination"], + }, + }, + }, + context, + ); + + expect(result.class).toBe(StellarRPCFailureClass.OP_RESULT_CODE); + expect(result.shouldRetry).toBe(false); + }); + + it("falls back to TX_RESULT_CODE when operations contains no known codes", () => { + const result = classifyStellarRPCFailure( + { + status: 400, + extras: { + result_codes: { + transaction: "tx_bad_seq", + operations: ["op_success"], + }, + }, + }, + context, + ); + + expect(result.class).toBe(StellarRPCFailureClass.TX_RESULT_CODE); + expect(result.shouldRetry).toBe(false); + }); + + it("falls back to UNKNOWN for unrecognized result-code shapes", () => { + const result = classifyStellarRPCFailure( + { + status: 400, + message: "future horizon error", + extras: { + result_codes: { + transaction: "tx_future_code", + operations: ["op_future_code"], + }, + }, + }, + context, + ); + + expect(result.class).toBe(StellarRPCFailureClass.UNKNOWN); expect(result.shouldRetry).toBe(true); + expectRedactedMessage(result); }); - it("falls back to UNKNOWN for everything else", () => { - const result = classifyStellarRPCFailure("oops", context); + it("falls back to UNKNOWN when result codes are nested in an unsupported shape", () => { + const result = classifyStellarRPCFailure( + { + response: { + data: { + extras: { + result_codes: { + transaction: "tx_bad_seq", + }, + }, + }, + }, + }, + context, + ); + expect(result.class).toBe(StellarRPCFailureClass.UNKNOWN); expect(result.shouldRetry).toBe(true); }); - it("sanitizes error objects to prevent data leakage", () => { - const error = new Error("Sensitive data: password=secret123"); - const result = classifyStellarRPCFailure(error, context); - expect(result.originalError).toHaveProperty("name"); - expect(result.originalError).toHaveProperty("message"); - expect((result.originalError as any).stack).toBeUndefined(); + it("handles missing extras, non-Error objects, null, and undefined safely", () => { + expect(classifyStellarRPCFailure({ status: 400 }, context).class).toBe( + StellarRPCFailureClass.UNKNOWN, + ); + expect(classifyStellarRPCFailure({ foo: "bar" }, context).class).toBe( + StellarRPCFailureClass.UNKNOWN, + ); + expect(classifyStellarRPCFailure(null, context).class).toBe( + StellarRPCFailureClass.UNKNOWN, + ); + expect(classifyStellarRPCFailure(undefined, context).class).toBe( + StellarRPCFailureClass.UNKNOWN, + ); + }); + + it("classifies timeout, network, http, malformed, and domain-specific branches", () => { + expect( + classifyStellarRPCFailure(new Error("upstream timeout while reading horizon"), context).class, + ).toBe(StellarRPCFailureClass.TIMEOUT); + expect( + classifyStellarRPCFailure(new Error("network connection reset by peer"), context).class, + ).toBe(StellarRPCFailureClass.NETWORK_ERROR); + expect(classifyStellarRPCFailure({ status: 429 }, context).class).toBe( + StellarRPCFailureClass.RATE_LIMIT, + ); + expect(classifyStellarRPCFailure({ status: 401 }, context).class).toBe( + StellarRPCFailureClass.UNAUTHORIZED, + ); + expect(classifyStellarRPCFailure({ status: 503 }, context).class).toBe( + StellarRPCFailureClass.UPSTREAM_ERROR, + ); + expect(classifyStellarRPCFailure(new SyntaxError("bad json"), context).class).toBe( + StellarRPCFailureClass.MALFORMED_RESPONSE, + ); + expect( + classifyStellarRPCFailure({ code: "CONTRACT_ERROR", message: "secret contract failure" }, context) + .class, + ).toBe(StellarRPCFailureClass.CONTRACT_ERROR); + expect( + classifyStellarRPCFailure({ code: "TRANSACTION_FAILED" }, context).class, + ).toBe(StellarRPCFailureClass.TRANSACTION_FAILED); + expect( + classifyStellarRPCFailure({ code: "INSUFFICIENT_FUNDS" }, context).class, + ).toBe(StellarRPCFailureClass.INSUFFICIENT_FUNDS); + expect(classifyStellarRPCFailure({ code: "BAD_SEQUENCE" }, context).class).toBe( + StellarRPCFailureClass.BAD_SEQUENCE, + ); + expect(classifyStellarRPCFailure({ code: "SIGNING_ERROR" }, context).class).toBe( + StellarRPCFailureClass.SIGNING_ERROR, + ); + }); + + it("redacts raw upstream messages for Error instances and primitive inputs", () => { + const errorResult = classifyStellarRPCFailure( + new Error("Sensitive data: password=secret123"), + context, + ); + const stringResult = classifyStellarRPCFailure("super secret upstream text", context); + + expect(errorResult.originalError).toEqual({ + name: "Error", + message: "UPSTREAM_MESSAGE_REDACTED", + }); + expect(stringResult.originalError).toEqual({ + message: "UPSTREAM_MESSAGE_REDACTED", + }); }); it("increases retry delay with attempt count for timeouts", () => { - const context1 = { operation: "test", attemptCount: 1 }; - const result1 = classifyStellarRPCFailure(new Error("timeout"), context1); - expect(result1.suggestedRetryDelayMs).toBeLessThanOrEqual(1000); + const attemptOne = classifyStellarRPCFailure(new Error("timeout"), { + ...context, + attemptCount: 1, + }); + const attemptTwo = classifyStellarRPCFailure(new Error("timeout"), { + ...context, + attemptCount: 2, + }); - const context2 = { operation: "test", attemptCount: 2 }; - const result2 = classifyStellarRPCFailure(new Error("timeout"), context2); - expect(result2.suggestedRetryDelayMs).toBeLessThanOrEqual(2000); + expect(attemptOne.suggestedRetryDelayMs).toBe(1000); + expect(attemptTwo.suggestedRetryDelayMs).toBe(2000); }); }); describe("shouldRetryStellarRPCFailure", () => { - const context = { operation: "test" }; + const timestamp = new Date().toISOString(); - it("returns false for non-retryable classes", () => { - const nonRetryableFailure = { - class: StellarRPCFailureClass.SIGNING_ERROR, - context, - originalError: {}, - timestamp: new Date().toISOString(), - shouldRetry: false, - }; - expect(shouldRetryStellarRPCFailure(nonRetryableFailure)).toBe(false); + it("returns false for protocol and explicit non-retryable failures", () => { + expect( + shouldRetryStellarRPCFailure({ + class: StellarRPCFailureClass.TX_RESULT_CODE, + context: { operation: "submit_payment", attemptCount: 1 }, + originalError: {}, + timestamp, + shouldRetry: false, + }), + ).toBe(false); + + expect( + shouldRetryStellarRPCFailure({ + class: StellarRPCFailureClass.OP_RESULT_CODE, + context: { operation: "submit_payment", attemptCount: 1 }, + originalError: {}, + timestamp, + shouldRetry: false, + }), + ).toBe(false); + + expect( + shouldRetryStellarRPCFailure({ + class: StellarRPCFailureClass.SIGNING_ERROR, + context: { operation: "submit_payment", attemptCount: 1 }, + originalError: {}, + timestamp, + shouldRetry: false, + }), + ).toBe(false); }); - it("returns false when max attempts exceeded", () => { - const failure = { - class: StellarRPCFailureClass.TIMEOUT, - context: { operation: "test", attemptCount: 5 }, - originalError: {}, - timestamp: new Date().toISOString(), - shouldRetry: true, - }; - expect(shouldRetryStellarRPCFailure(failure, 3)).toBe(false); + it("returns false when the max attempt budget is exhausted", () => { + expect( + shouldRetryStellarRPCFailure({ + class: StellarRPCFailureClass.TIMEOUT, + context: { operation: "submit_payment", attemptCount: 5 }, + originalError: {}, + timestamp, + shouldRetry: true, + }, 3), + ).toBe(false); }); +}); - it('classifies malformed payload failures', () => { - expect(classifyStellarRPCFailure(new SyntaxError('bad json'), mockContext).class).toBe( - StellarRPCFailureClass.MALFORMED_RESPONSE, - ); +describe("createStellarErrorResponse", () => { + it("returns safe protocol messages for tx and op result-code failures", () => { + expect( + createStellarErrorResponse({ + class: StellarRPCFailureClass.TX_RESULT_CODE, + context: { operation: "submit_payment" }, + originalError: {}, + timestamp: new Date().toISOString(), + shouldRetry: false, + }).message, + ).toBe("Stellar transaction protocol error"); + + expect( + createStellarErrorResponse({ + class: StellarRPCFailureClass.OP_RESULT_CODE, + context: { operation: "submit_payment" }, + originalError: {}, + timestamp: new Date().toISOString(), + shouldRetry: false, + }).message, + ).toBe("Stellar operation protocol error"); }); +}); - it('falls back to UNKNOWN for everything else', () => { - expect(classifyStellarRPCFailure('oops', mockContext).class).toBe(StellarRPCFailureClass.UNKNOWN); +describe("isStellarRPCRetryable", () => { + it("marks protocol result codes as non-retryable", () => { + expect(isStellarRPCRetryable(StellarRPCFailureClass.TX_RESULT_CODE)).toBe(false); + expect(isStellarRPCRetryable(StellarRPCFailureClass.OP_RESULT_CODE)).toBe(false); }); }); diff --git a/src/lib/stellarRpcFailure.ts b/src/lib/stellarRpcFailure.ts index 8d7fbb79..a98d1c9f 100644 --- a/src/lib/stellarRpcFailure.ts +++ b/src/lib/stellarRpcFailure.ts @@ -26,13 +26,6 @@ export enum StellarRPCFailureClass { TX_RESULT_CODE = "TX_RESULT_CODE", /** Operation-level result code from Horizon (e.g. op_no_destination, op_underfunded). */ OP_RESULT_CODE = "OP_RESULT_CODE", - NETWORK_ERROR = "NETWORK_ERROR", - CONTRACT_ERROR = "CONTRACT_ERROR", - TRANSACTION_FAILED = "TRANSACTION_FAILED", - INSUFFICIENT_FUNDS = "INSUFFICIENT_FUNDS", - BAD_SEQUENCE = "BAD_SEQUENCE", - SIGNING_ERROR = "SIGNING_ERROR", - VALIDATION_ERROR = "VALIDATION_ERROR", UNKNOWN = "UNKNOWN", } @@ -44,30 +37,10 @@ export interface StellarRPCFailureContext { attemptCount?: number; accountId?: string; transactionId?: string; - [key: string]: unknown; -} - -/** - * @dev Structured failure information for Stellar RPC errors. - */ -export interface StellarRPCFailure { - class: StellarRPCFailureClass; - context: StellarRPCFailureContext; - originalError: unknown; - timestamp: string; - shouldRetry: boolean; - suggestedRetryDelayMs?: number; -} - -/** - * @dev Context for Stellar RPC failures to assist in retry logic and logging. - */ -export interface StellarRPCFailureContext { - operation: string; offeringId?: string; periodId?: string; requestId?: string; - attemptCount?: number; + [key: string]: unknown; } /** @@ -76,7 +49,7 @@ export interface StellarRPCFailureContext { export interface StellarRPCFailure { class: StellarRPCFailureClass; context: StellarRPCFailureContext; - originalError: any; + originalError: unknown; timestamp: string; shouldRetry: boolean; suggestedRetryDelayMs?: number; @@ -387,34 +360,39 @@ export function classifyStellarRPCFailure( * @dev Sanitizes error objects to prevent sensitive data leakage. */ function sanitizeError(error: unknown): unknown { + const redactedMessage = "UPSTREAM_MESSAGE_REDACTED"; + if (error instanceof Error) { return { name: error.name, - message: error.message, - stack: process.env.NODE_ENV === "development" ? error.stack : undefined, + message: redactedMessage, }; } if (typeof error === "object" && error !== null) { - const sanitized: any = {}; + const sanitized: Record = {}; const allowedKeys = [ "status", "statusText", "code", - "message", "result_xdr", ]; for (const key of allowedKeys) { if (key in error) { - sanitized[key] = (error as any)[key]; + sanitized[key] = (error as Record)[key]; } } + if ("message" in error) { + // Preserve shape for diagnostics without disclosing upstream text. + sanitized.message = redactedMessage; + } + return sanitized; } - return { message: String(error) }; + return { message: redactedMessage }; } /** diff --git a/src/routes/payouts.test.ts b/src/routes/payouts.test.ts index f19d977e..600e6b17 100644 --- a/src/routes/payouts.test.ts +++ b/src/routes/payouts.test.ts @@ -538,28 +538,28 @@ describe('classifyStellarRPCFailure – result codes', () => { it.each(txCodes)('classifies tx code "%s" as TX_RESULT_CODE', (code) => { const err = { status: 400, extras: { result_codes: { transaction: code, operations: [] } } }; - assert.strictEqual(classifyStellarRPCFailure(err), StellarRPCFailureClass.TX_RESULT_CODE); + assert.strictEqual(classifyStellarRPCFailure(err).class, StellarRPCFailureClass.TX_RESULT_CODE); }); it('classifies tx_bad_seq as TX_RESULT_CODE (explicit)', () => { const err = { status: 400, extras: { result_codes: { transaction: 'tx_bad_seq' } } }; - assert.strictEqual(classifyStellarRPCFailure(err), StellarRPCFailureClass.TX_RESULT_CODE); + assert.strictEqual(classifyStellarRPCFailure(err).class, StellarRPCFailureClass.TX_RESULT_CODE); }); it('classifies tx_insufficient_fee as TX_RESULT_CODE (explicit)', () => { const err = { status: 400, extras: { result_codes: { transaction: 'tx_insufficient_fee' } } }; - assert.strictEqual(classifyStellarRPCFailure(err), StellarRPCFailureClass.TX_RESULT_CODE); + assert.strictEqual(classifyStellarRPCFailure(err).class, StellarRPCFailureClass.TX_RESULT_CODE); }); it('classifies tx_bad_auth as TX_RESULT_CODE', () => { const err = { status: 400, extras: { result_codes: { transaction: 'tx_bad_auth' } } }; - assert.strictEqual(classifyStellarRPCFailure(err), StellarRPCFailureClass.TX_RESULT_CODE); + assert.strictEqual(classifyStellarRPCFailure(err).class, StellarRPCFailureClass.TX_RESULT_CODE); }); it('does not classify unknown tx code as TX_RESULT_CODE', () => { const err = { status: 400, extras: { result_codes: { transaction: 'tx_unknown_future_code' } } }; // Falls through to UNKNOWN since status 400 has no other handler - assert.strictEqual(classifyStellarRPCFailure(err), StellarRPCFailureClass.UNKNOWN); + assert.strictEqual(classifyStellarRPCFailure(err).class, StellarRPCFailureClass.UNKNOWN); }); }); @@ -573,7 +573,7 @@ describe('classifyStellarRPCFailure – result codes', () => { status: 400, extras: { result_codes: { transaction: 'tx_failed', operations: [code] } }, }; - assert.strictEqual(classifyStellarRPCFailure(err), StellarRPCFailureClass.OP_RESULT_CODE); + assert.strictEqual(classifyStellarRPCFailure(err).class, StellarRPCFailureClass.OP_RESULT_CODE); }); it('classifies op_no_destination as OP_RESULT_CODE (explicit)', () => { @@ -581,7 +581,7 @@ describe('classifyStellarRPCFailure – result codes', () => { status: 400, extras: { result_codes: { transaction: 'tx_failed', operations: ['op_no_destination'] } }, }; - assert.strictEqual(classifyStellarRPCFailure(err), StellarRPCFailureClass.OP_RESULT_CODE); + assert.strictEqual(classifyStellarRPCFailure(err).class, StellarRPCFailureClass.OP_RESULT_CODE); }); it('classifies op_underfunded as OP_RESULT_CODE (explicit)', () => { @@ -589,7 +589,7 @@ describe('classifyStellarRPCFailure – result codes', () => { status: 400, extras: { result_codes: { transaction: 'tx_failed', operations: ['op_underfunded'] } }, }; - assert.strictEqual(classifyStellarRPCFailure(err), StellarRPCFailureClass.OP_RESULT_CODE); + assert.strictEqual(classifyStellarRPCFailure(err).class, StellarRPCFailureClass.OP_RESULT_CODE); }); it('op codes take precedence over tx codes', () => { @@ -599,7 +599,7 @@ describe('classifyStellarRPCFailure – result codes', () => { result_codes: { transaction: 'tx_failed', operations: ['op_no_trust', 'op_success'] }, }, }; - assert.strictEqual(classifyStellarRPCFailure(err), StellarRPCFailureClass.OP_RESULT_CODE); + assert.strictEqual(classifyStellarRPCFailure(err).class, StellarRPCFailureClass.OP_RESULT_CODE); }); it('falls back to TX_RESULT_CODE when ops array has no known codes', () => { @@ -609,7 +609,7 @@ describe('classifyStellarRPCFailure – result codes', () => { result_codes: { transaction: 'tx_bad_seq', operations: ['op_success'] }, }, }; - assert.strictEqual(classifyStellarRPCFailure(err), StellarRPCFailureClass.TX_RESULT_CODE); + assert.strictEqual(classifyStellarRPCFailure(err).class, StellarRPCFailureClass.TX_RESULT_CODE); }); it('handles missing operations array gracefully', () => { @@ -617,7 +617,7 @@ describe('classifyStellarRPCFailure – result codes', () => { status: 400, extras: { result_codes: { transaction: 'tx_bad_seq' } }, }; - assert.strictEqual(classifyStellarRPCFailure(err), StellarRPCFailureClass.TX_RESULT_CODE); + assert.strictEqual(classifyStellarRPCFailure(err).class, StellarRPCFailureClass.TX_RESULT_CODE); }); }); @@ -625,23 +625,23 @@ describe('classifyStellarRPCFailure – result codes', () => { describe('http status codes', () => { it('classifies 429 as RATE_LIMIT', () => { - assert.strictEqual(classifyStellarRPCFailure({ status: 429 }), StellarRPCFailureClass.RATE_LIMIT); + assert.strictEqual(classifyStellarRPCFailure({ status: 429 }).class, StellarRPCFailureClass.RATE_LIMIT); }); it('classifies 401 as UNAUTHORIZED', () => { - assert.strictEqual(classifyStellarRPCFailure({ status: 401 }), StellarRPCFailureClass.UNAUTHORIZED); + assert.strictEqual(classifyStellarRPCFailure({ status: 401 }).class, StellarRPCFailureClass.UNAUTHORIZED); }); it('classifies 403 as UNAUTHORIZED', () => { - assert.strictEqual(classifyStellarRPCFailure({ status: 403 }), StellarRPCFailureClass.UNAUTHORIZED); + assert.strictEqual(classifyStellarRPCFailure({ status: 403 }).class, StellarRPCFailureClass.UNAUTHORIZED); }); it('classifies 500 as UPSTREAM_ERROR', () => { - assert.strictEqual(classifyStellarRPCFailure({ status: 500 }), StellarRPCFailureClass.UPSTREAM_ERROR); + assert.strictEqual(classifyStellarRPCFailure({ status: 500 }).class, StellarRPCFailureClass.UPSTREAM_ERROR); }); it('classifies 503 as UPSTREAM_ERROR', () => { - assert.strictEqual(classifyStellarRPCFailure({ status: 503 }), StellarRPCFailureClass.UPSTREAM_ERROR); + assert.strictEqual(classifyStellarRPCFailure({ status: 503 }).class, StellarRPCFailureClass.UPSTREAM_ERROR); }); }); @@ -651,12 +651,12 @@ describe('classifyStellarRPCFailure – result codes', () => { it('classifies AbortError as TIMEOUT', () => { const err = new Error('aborted'); err.name = 'AbortError'; - assert.strictEqual(classifyStellarRPCFailure(err), StellarRPCFailureClass.TIMEOUT); + assert.strictEqual(classifyStellarRPCFailure(err).class, StellarRPCFailureClass.TIMEOUT); }); it('classifies message containing "timeout" as TIMEOUT', () => { assert.strictEqual( - classifyStellarRPCFailure(new Error('Request timeout after 30s')), + classifyStellarRPCFailure(new Error('Request timeout after 30s')).class, StellarRPCFailureClass.TIMEOUT, ); }); @@ -667,7 +667,7 @@ describe('classifyStellarRPCFailure – result codes', () => { describe('malformed response', () => { it('classifies SyntaxError as MALFORMED_RESPONSE', () => { assert.strictEqual( - classifyStellarRPCFailure(new SyntaxError('Unexpected token')), + classifyStellarRPCFailure(new SyntaxError('Unexpected token')).class, StellarRPCFailureClass.MALFORMED_RESPONSE, ); }); @@ -677,39 +677,40 @@ describe('classifyStellarRPCFailure – result codes', () => { describe('unknown fallback', () => { it('classifies null as UNKNOWN', () => { - assert.strictEqual(classifyStellarRPCFailure(null), StellarRPCFailureClass.UNKNOWN); + assert.strictEqual(classifyStellarRPCFailure(null).class, StellarRPCFailureClass.UNKNOWN); }); it('classifies plain string as UNKNOWN', () => { - assert.strictEqual(classifyStellarRPCFailure('some error'), StellarRPCFailureClass.UNKNOWN); + assert.strictEqual(classifyStellarRPCFailure('some error').class, StellarRPCFailureClass.UNKNOWN); }); it('classifies generic Error as UNKNOWN', () => { - assert.strictEqual(classifyStellarRPCFailure(new Error('something')), StellarRPCFailureClass.UNKNOWN); + assert.strictEqual(classifyStellarRPCFailure(new Error('something')).class, StellarRPCFailureClass.UNKNOWN); }); it('classifies object with no status as UNKNOWN', () => { - assert.strictEqual(classifyStellarRPCFailure({ message: 'oops' }), StellarRPCFailureClass.UNKNOWN); + assert.strictEqual(classifyStellarRPCFailure({ message: 'oops' }).class, StellarRPCFailureClass.UNKNOWN); }); }); // ── Security: no raw upstream strings leak ──────────────────────────────── describe('security: raw upstream strings do not leak', () => { - it('returns only a StellarRPCFailureClass enum value, never the raw error', () => { + it('returns a classified object without leaking the raw upstream message', () => { const sensitiveErr = { status: 400, + message: 'secret horizon message', extras: { result_codes: { transaction: 'tx_bad_seq' }, envelope_xdr: 'AAAA...sensitive...XDR', result_xdr: 'AAAA...sensitive...result', }, }; - const cls = classifyStellarRPCFailure(sensitiveErr); - // Result must be one of the known enum values - assert(Object.values(StellarRPCFailureClass).includes(cls)); - // Must not be the raw error object - assert.notStrictEqual(cls as any, sensitiveErr); + const failure = classifyStellarRPCFailure(sensitiveErr); + assert.strictEqual(failure.class, StellarRPCFailureClass.TX_RESULT_CODE); + assert.notStrictEqual(failure.originalError as any, sensitiveErr); + assert.strictEqual((failure.originalError as any).message, 'UPSTREAM_MESSAGE_REDACTED'); + assert(!JSON.stringify(failure.originalError).includes('secret horizon message')); }); }); }); @@ -809,7 +810,7 @@ describe('payout repo retry storm', () => { assert.strictEqual((repo.listPayoutsByInvestor as jest.Mock).mock.calls.length, 1, 'no retry on protocol error'); // Verify the error is classified as non-retryable assert.strictEqual( - isStellarRPCRetryable(classifyStellarRPCFailure(txErr)), + isStellarRPCRetryable(classifyStellarRPCFailure(txErr).class), false, ); }); @@ -823,7 +824,7 @@ describe('payout repo retry storm', () => { await handlers.listPayouts(makeReq({ id: 'inv-r', role: 'investor' }), res, (e: any) => { capturedErr = e; }); assert(capturedErr !== null); assert.strictEqual((repo.listPayoutsByInvestor as jest.Mock).mock.calls.length, 1); - assert.strictEqual(classifyStellarRPCFailure(rateLimitErr), StellarRPCFailureClass.RATE_LIMIT); + assert.strictEqual(classifyStellarRPCFailure(rateLimitErr).class, StellarRPCFailureClass.RATE_LIMIT); assert.strictEqual(isStellarRPCRetryable(StellarRPCFailureClass.RATE_LIMIT), false); }); @@ -838,7 +839,7 @@ describe('payout repo retry storm', () => { await handlers.listPayouts(makeReq({ id: 'inv-r', role: 'investor' }), res, (e: any) => { capturedErr = e; }); assert(capturedErr !== null); assert.strictEqual((repo.listPayoutsByInvestor as jest.Mock).mock.calls.length, 1); - assert.strictEqual(classifyStellarRPCFailure(timeoutErr), StellarRPCFailureClass.TIMEOUT); + assert.strictEqual(classifyStellarRPCFailure(timeoutErr).class, StellarRPCFailureClass.TIMEOUT); assert.strictEqual(isStellarRPCRetryable(StellarRPCFailureClass.TIMEOUT), true); }); });