fix(billing): route Soroban simulation failures through the standard error envelope - #1429
Merged
greatest0fallt1me merged 3 commits intoOct 1, 2026
Conversation
`package.json` and `jest.env-setup.cjs` were removed by commit 599ab6e ("security: Clarify which tests need Postgres or containers (CalloraOrg#1338)"), a docs/test-scoping change that also dropped README.md. The result is that the repository cannot build, lint, typecheck or run a single test on main: every npm script is missing and `npm ci` fails outright, which also fails CI. Restore both files verbatim from the commit before the deletion (95f3700). `jest.config.cjs` still references `jest.env-setup.cjs` through `setupFiles`, so test runs are broken without it as well. README.md is intentionally not restored here: it is a 561-line document with no effect on the build, and re-adding it does not belong in this change.
…elope
`sendSimulationFailure` and the `SorobanRpcError` catch branch in
`routes/billing/deduct.ts` wrote `{ error, code, simulationDetails }`
straight to the response with `res.status(502).json(...)`. That bypassed
`buildErrorEnvelope` and the request-id middleware, so this one failure path
was the only place a client could not parse with the standard
`success/error/requestId/timestamp` envelope, and support had no
`requestId` to correlate a simulation failure with.
Both paths now throw `SimulationFailedError` — a `BadGatewayError` (502)
carrying the canonical `SIMULATION_FAILED` code — and let the global error
handler render it. `console.warn` is replaced by `logger.warn`, which logs
the redacted summary so the diagnostic detail is still available
server-side.
Redaction is enforced in the error constructor, not at the call site: raw
RPC diagnostics contain account addresses, balances, XDR and signatures, so
constructing the error with unredacted input is impossible to get wrong.
`normalizeError` and `buildErrorEnvelope` then re-validate the summary
against an explicit four-field whitelist (`errorCode`, `errorMessage`,
`eventCount`, `footprintPresent`), so no future caller can widen the
response body by accident.
Supporting changes:
- `SIMULATION_FAILED` is added to `PUBLIC_ERROR_CODES`; without it
`normalizePublicCode` would have downgraded the code to `BAD_GATEWAY`.
- `errorEnvelopeSchema` and `ErrorEnvelope` gain an optional, additive
`error.simulationDetails`, and `envelopeMiddleware` preserves it for
responses that emit the canonical shape directly.
- `SimulationFailedError` is detected by a marker flag rather than
`instanceof`: `AppError` re-points `this` at `AppError.prototype`, which
severs every subclass prototype, so `instanceof` is always false. This is
the same reason `isAppError` uses a flag.
Tests: `deduct.test.ts` used the `x-user-id` header, which `requireAuth`
stopped trusting, so four of its five cases were 401s that never reached the
code under test. They now mint real HS256 tokens, and deterministic cases
were added for: the standard envelope with code/requestId, redaction (the
raw address, balance, contract id and secret must not appear anywhere in the
body), the `SorobanRpcError` path, unchanged mapping of the other
`SorobanRpcError` categories, the plain `PaymentRequiredError` path, and an
assertion that no `console.*` call is made. Suite went from 4 failures to
12 passing; the full repository test run improves by exactly this suite with
no new failures.
|
@iyanumajekodunmi756 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
# Conflicts: # src/middleware/errorHandler.ts # src/routes/billing/deduct.test.ts # src/routes/billing/deduct.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1285
Summary
sendSimulationFailureand theSorobanRpcErrorcatch branch insrc/routes/billing/deduct.tswrote{ error, code, simulationDetails }directly to the response with
res.status(502).json(...). That bypassedbuildErrorEnvelopeand the request-id middleware, so this was the one failurepath in the billing API that a client could not parse with the standard
success/error/requestId/timestampenvelope — and support had norequestIdto correlate a simulation failure with.Both paths now throw
SimulationFailedError, aBadGatewayError(502)carrying the canonical
SIMULATION_FAILEDcode and a redacted simulationsummary, and let the global error handler render the envelope.
console.warnisreplaced by
logger.warn.Affected modules
src/routes/billing/deduct.tssendSimulationFailurereplaced bysimulationFailureError+logSimulationFailure; both 502-write paths nownext(...)aSimulationFailedError;console.warn→logger.warn.src/errors/index.tsSimulationFailedError extends BadGatewayErrorthat redacts in its constructor, plus anisSimulationFailedErrorguard.src/errors/errorEnvelopePolicy.tsSIMULATION_FAILEDadded toPUBLIC_ERROR_CODES; newsafeSimulationDetailswhitelist;normalizeErroraccepts and sanitises simulation details.src/middleware/envelope.tserrorEnvelopeSchemaandbuildErrorEnvelopegain an optional, additiveerror.simulationDetails;envelopeMiddlewarepreserves it for responses that emit the canonical shape directly.src/middleware/errorHandler.tsSimulationFailedErrorand passes it throughnormalizeError/buildErrorEnvelope.src/types/ResponseEnvelope.tsErrorEnvelopedocuments the optionalsimulationDetailsmember.src/routes/billing/deduct.test.tsProposed state / invariant changes
SimulationFailedErroris redacted at construction. Redaction lives in theconstructor rather than at the call site, so it is impossible to construct this
error with unredacted input. Cost: a caller cannot choose to publish raw
diagnostics; that is intentional — raw RPC payloads contain account addresses,
balances, XDR and signatures.
safeSimulationDetailsaccepts onlyerrorCode(string|finite number),errorMessage(string),eventCount(non-negative finite integer) andfootprintPresent(boolean), and bounds each field. Anything else is dropped,so a future caller cannot widen the response body by accident.
error.simulationDetailsisonly present for simulation failures and is validated by the existing
envelopeSchema, so existing clients that ignore unknown keys are unaffected.Why
SIMULATION_FAILEDhad to be whitelistednormalizeErrormaps the error code throughnormalizePublicCode, whichsubstitutes
publicCodeForStatus(502) = BAD_GATEWAYfor any code that is not inPUBLIC_ERROR_CODES.SIMULATION_FAILEDwas absent from that list, so throwingthe new error without adding it would have produced a correctly-shaped envelope
with the wrong code.
SIMULATION_FAILEDis a canonical entry in the errorcatalog (
src/errors/codes.ts,docs/openapi.json, generated fromdocs/error-codes.yaml), so adding it restores the documented code rather thaninventing one.
Note:
instanceofdoes not work forAppErrorsubclassesAppError's constructor callsObject.setPrototypeOf(this, AppError.prototype),which severs the prototype chain of every subclass. Consequently
err instanceof SimulationFailedErroris alwaysfalse.isAppErroralreadyworks around this with an
isAppErrorflag;SimulationFailedErrormirrors thatpattern with an
isSimulationFailedErrormarker. This is noted in the classdoc-comment because it is a trap for the next author.
Compatibility
error.The
success/error.code/error.message/requestId/timestampskeleton is untouched, and previously there was no envelope at all on this
path, so nothing that parsed this response can regress.
502); the code changes from a bareSIMULATION_FAILEDstring in a non-standard body to the same code in thestandard envelope, which is the point of the issue.
SorobanRpcErrorcategories (INSUFFICIENT_BALANCE→ 402,TIMEOUT→ 504,CONTRACT_ERROR/NETWORK_ERROR→ 502) keep their existingmappings, asserted by a new test.
src/routes/billing.tshas a parallelsendSimulationFailurewith the sameshape. It is deliberately not touched here because the issue scopes the
change to
deduct.ts; it is an obvious follow-up.Security and failure-mode handling
contract address, balance, contract id or secret seed from the input payload.
Two independent redaction passes run (constructor, then envelope whitelist).
logger.warnstill records thefailure, with the same redacted summary, so operators keep the signal without
the sensitive material. The logging helper receives the raw details and
redacts them for the log, so
eventCount/footprintPresentare preserved(re-redacting an already-redacted summary is lossy, which is why the log path
and the response path deliberately redact different inputs).
console.*in the route. Asserted by test, not just by inspection.failed" is still a 502, now machine-parseable.
Test strategy
deduct.test.tspreviously authenticated with thex-user-idheader. SincerequireAuthstopped trusting forwarded headers without a signed gatewayassertion (
computeGatewaySignature/TRUST_FORWARDED_USER_ID), four of itsfive cases were 401s that never reached the code under test — the suite was
green-by-accident-inverted, i.e. failing. It now mints real HS256 tokens with a
test
JWT_SECRET, matching the pattern used bysrc/routes/credits.test.ts.12 tests, all passing:
developerIdvalidationreturns 401 without authreturns 401 for an x-user-id header without an authenticated tokenreturns the standard envelope with SIMULATION_FAILED and a requestIdenvelopeSchema.safeParse(body).success.publishes only redacted simulation detailsroutes a SorobanRpcError carrying simulation details through the same envelopestill maps non-simulation SorobanRpcError categories to their own codesINSUFFICIENT_BALANCE, nosimulationDetails.keeps a plain deduction failure on PaymentRequiredErrorBILLING_DEDUCTION_FAILED, nosimulationDetails.never writes simulation diagnostics to the consoleconsole.*remains".Verification
Environment: Node 20.20.2 / npm 10.8.2 (matching CI's
node-version: 20).Regression check against the unmodified baseline (measured by stashing this
branch's
src/changes and re-running the full suite):The difference is exactly this suite going from 4 failures to 12 passes. No
new failures were introduced — a diff of the failing-suite lists is empty in
the "newly failing" direction.
Prerequisite commits (included, clearly separated)
Both commits below are required for anything in this repository to build or
test, and are not part of the fix itself. They are separate commits so they can
be reviewed, cherry-picked or dropped independently.
fix: restore accidentally deleted package.json and jest env setup—package.jsonandjest.env-setup.cjswere deleted by599ab6e("security: Clarify which tests need Postgres or containers (Clarify which tests need Postgres or containers #1338)"), a
docs/test-scoping change that also dropped
README.md. Without the manifestthere is no
npm ci, nonpm run build, nonpm test, and all CI jobsfail. Both files are restored verbatim from the commit before the deletion
(
95f3700).fix: restore deleted adminAuth middleware and repair broken module wiring—
src/middleware/adminAuth.tswas deleted by092ece9while seven modulesstill import it, and three more files contain ESM-fatal defects (a re-export
of a symbol that does not exist, a duplicated
constdeclaration, andrequire.maininside an ES module). Full detail in that commit's message.Known pre-existing failures (not caused by, and not fixed by, this PR)
mainis currently mid-repair. The following are reproducible on a pristinecheckout and are out of scope for this issue:
npx tsc --noEmitreports 273 type errors (140 in production source across37 files). Upstream CI marks its
typecheckandbuildstepscontinue-on-error: truefor this reason.scripts/check-migrations.ts— the only step in.github/workflows/ci.ymlwithout
continue-on-error— fails on main:Duplicate new migration prefix 24(both0024_hash_api_keys.sqland0024_idempotency_store_scope.sqlare tracked) andDestructive migration "0024_idempotency_store_scope.sql" requires -- destructive-approved: #<issue>(it contains
DROP CONSTRAINT/DROP INDEX). Because this job fails onmainitself, it fails for every PR, including this one. Fixing it meansrenaming a migration and adding an approval marker, which is a migration-policy
decision for the maintainers rather than a change to fold into this issue.
src/services/auditService.tsno longer exportsAuditService/defaultAuditServicealthough six modules import them, andsrc/routes/gatewayRoutes.tsreferencescorrelationMiddleware,envandCircuitBreakerOpenErrorthat are not defined or imported. These need thedeleted code restored and are not safely inferable.
Acceptance criteria mapping
SIMULATION_FAILEDandrequestIdsimulationFailureError→SimulationFailedError→errorHandler; asserted withenvelopeSchema.safeParseandrequestIdequalitysimulationDetailsremain redactedSimulationFailedErrorconstructor +safeSimulationDetailswhitelist; leakage assertion over the serialized bodyconsole.*calls remain indeduct.tslogger.warn;never writes simulation diagnostics to the consolededucttests are updatednpm test -- src/routes/billing/deduct.test.ts src/lib/simulationDiagnostics.test.tsNon-goals respected
No typo-only or cosmetic changes, no unrelated refactors, no dependency
upgrades, and no validation weakened to make tests pass.