From 4c237d72ef12348e28ec846fb0cf60c021cd16e1 Mon Sep 17 00:00:00 2001 From: droplet-rl Date: Wed, 27 May 2026 09:17:30 +0000 Subject: [PATCH 1/3] improve(svm): add describeSolanaError helper for log-friendly error extraction `SolanaError` carries the rich diagnostics (program logs, accounts touched, underlying `TransactionError` / `InstructionError`) on its `context` and `cause` fields. JSON loggers either replace an `Error` value with `.stack` or `JSON.stringify` it; both routes silently drop those fields. Consumers end up with `SolanaError: Transaction simulation failed at ...` and no way to tell a benign race apart from a real program-error. Add `describeSolanaError(err)` next to `isSolanaError` in `arch/svm/provider`. Returns `{ solanaError: { name, message?, code, context, cause? } }` for SolanaErrors (recursing on SolanaError causes; falling back to `{ message }` for non-Solana Error causes) and `{}` otherwise so callers can spread the result unconditionally: catch (err) { logger.error({ ..., error: err, ...describeSolanaError(err) }); } Co-Authored-By: Claude Opus 4.7 --- src/arch/svm/provider.ts | 58 ++++++++++++++++++ test/describeSolanaError.test.ts | 100 +++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 test/describeSolanaError.test.ts diff --git a/src/arch/svm/provider.ts b/src/arch/svm/provider.ts index d8dc51588..4f7b4c3a7 100644 --- a/src/arch/svm/provider.ts +++ b/src/arch/svm/provider.ts @@ -54,3 +54,61 @@ export interface SolanaErrorLike { export function isSolanaError(error: unknown): error is SolanaErrorLike { return _isSolanaError(error) || is(error, SolanaErrorStruct); } + +/** + * Structured description of a SolanaError suitable for logging. + * + * `code` and `context` mirror the underlying SolanaError's `context.__code` and `context` — + * `context` carries the rich diagnostic payload for typed errors (e.g. for + * `SVM_TRANSACTION_PREFLIGHT_FAILURE`, the `RpcSimulateTransactionResult` with `logs[]`, + * `accounts`, `returnData`, `unitsConsumed`). `cause` is recursively described when the + * underlying cause is itself a SolanaError (e.g. the `TransactionError` / + * `InstructionError` wrapped inside a preflight failure). + */ +export type SolanaErrorDescription = { + name: string; + message?: string; + code: number; + context: SolanaErrorLike["context"]; + cause?: SolanaErrorDescription | { message: string }; +}; + +/** + * Extract a structured, log-friendly description of a SolanaError. Returns `{}` for + * anything that isn't a SolanaError so callers can spread the result unconditionally. + * + * Motivation: most JSON logger formatters either (a) replace an `Error` field with its + * `.stack` string or (b) JSON.stringify it, which drops `context` and `cause` on + * SolanaErrors because those are own enumerable properties on the SolanaError instance + * but the loggers consult `.stack`/`.message` instead. This helper produces a plain + * object holding the fields you actually need to diagnose an SVM failure (program logs, + * underlying instruction error, etc.) that survives any standard serializer. + * + * @example + * try { await sendAndConfirmSolanaTransaction(tx, provider); } + * catch (err) { + * logger.error({ at: "...", message: "...", error: err, ...describeSolanaError(err) }); + * } + */ +export function describeSolanaError(err: unknown): { solanaError?: SolanaErrorDescription } { + if (!isSolanaError(err)) { + return {}; + } + const solanaError: SolanaErrorDescription = { + name: err.name, + code: err.context.__code, + context: err.context, + }; + if (err instanceof Error) { + solanaError.message = err.message; + } + if (err.cause !== undefined) { + const describedCause = describeSolanaError(err.cause); + if (describedCause.solanaError) { + solanaError.cause = describedCause.solanaError; + } else if (err.cause instanceof Error) { + solanaError.cause = { message: err.cause.message }; + } + } + return { solanaError }; +} diff --git a/test/describeSolanaError.test.ts b/test/describeSolanaError.test.ts new file mode 100644 index 000000000..c004d59f8 --- /dev/null +++ b/test/describeSolanaError.test.ts @@ -0,0 +1,100 @@ +import { describeSolanaError, SVM_TRANSACTION_PREFLIGHT_FAILURE, SVM_SLOT_SKIPPED } from "../src/arch/svm/provider"; +import { expect } from "./utils"; + +describe("describeSolanaError", () => { + it("returns an empty object for non-Solana errors", () => { + expect(describeSolanaError(new Error("regular"))).to.deep.equal({}); + expect(describeSolanaError("oops")).to.deep.equal({}); + expect(describeSolanaError(undefined)).to.deep.equal({}); + expect(describeSolanaError(null)).to.deep.equal({}); + expect(describeSolanaError({ random: "object" })).to.deep.equal({}); + }); + + it("extracts name, code, and context from a SolanaError-like object", () => { + const err = { + name: "SolanaError", + context: { + __code: SVM_TRANSACTION_PREFLIGHT_FAILURE, + logs: ["Program log: refund leaf already executed"], + accounts: null, + unitsConsumed: 4321, + }, + }; + + const result = describeSolanaError(err); + expect(result.solanaError).to.not.be.undefined; + expect(result.solanaError?.name).to.equal("SolanaError"); + expect(result.solanaError?.code).to.equal(SVM_TRANSACTION_PREFLIGHT_FAILURE); + expect(result.solanaError?.context).to.deep.equal(err.context); + // message field is only populated for real Error instances; the plain object form omits it. + expect(result.solanaError?.message).to.be.undefined; + }); + + it("populates `message` when the input is an Error instance", () => { + class FakeSolanaError extends Error { + readonly name = "SolanaError"; + readonly context = { __code: SVM_SLOT_SKIPPED }; + } + const err = new FakeSolanaError("Slot was skipped"); + + const result = describeSolanaError(err); + expect(result.solanaError?.message).to.equal("Slot was skipped"); + }); + + it("recursively describes a SolanaError cause", () => { + const inner = { + name: "SolanaError", + context: { __code: 4615001, index: 3 }, + }; + const outer = { + name: "SolanaError", + context: { __code: SVM_TRANSACTION_PREFLIGHT_FAILURE, logs: ["Program log: x"] }, + cause: inner, + }; + + const result = describeSolanaError(outer); + expect(result.solanaError?.cause).to.deep.equal({ + name: "SolanaError", + code: 4615001, + context: inner.context, + }); + }); + + it("falls back to { message } when the cause is a non-Solana Error", () => { + const outer = { + name: "SolanaError", + context: { __code: SVM_TRANSACTION_PREFLIGHT_FAILURE }, + cause: new Error("network blip"), + }; + + const result = describeSolanaError(outer); + expect(result.solanaError?.cause).to.deep.equal({ message: "network blip" }); + }); + + it("omits cause when the SolanaError has no cause", () => { + const err = { + name: "SolanaError", + context: { __code: SVM_SLOT_SKIPPED }, + }; + + const result = describeSolanaError(err); + expect(result.solanaError?.cause).to.be.undefined; + }); + + it("survives JSON serialization round-trips (structurally cloned error)", () => { + const err = JSON.parse( + JSON.stringify({ + name: "SolanaError", + context: { __code: SVM_TRANSACTION_PREFLIGHT_FAILURE, logs: ["a", "b"] }, + cause: { + name: "SolanaError", + context: { __code: 4615001 }, + }, + }) + ); + + const result = describeSolanaError(err); + expect(result.solanaError?.code).to.equal(SVM_TRANSACTION_PREFLIGHT_FAILURE); + expect(result.solanaError?.cause).to.deep.include({ code: 4615001 }); + }); +}); From 14f034f2fa6d5d55c10da2dbdac9bc60661deea7 Mon Sep 17 00:00:00 2001 From: droplet-rl <284132418+droplet-rl@users.noreply.github.com> Date: Wed, 27 May 2026 09:37:59 +0000 Subject: [PATCH 2/3] review(svm): tighten describeSolanaError comments, relocate test - Trim verbose JSDoc/block comments in src/arch/svm/provider.ts. - Move describeSolanaError.test.ts under test/providers/solana/ alongside the other SVM provider tests. Addresses pxrl review feedback on #1446. Co-Authored-By: Claude Opus 4.7 --- src/arch/svm/provider.ts | 58 +++---------------- .../solana}/describeSolanaError.test.ts | 8 ++- 2 files changed, 15 insertions(+), 51 deletions(-) rename test/{ => providers/solana}/describeSolanaError.test.ts (95%) diff --git a/src/arch/svm/provider.ts b/src/arch/svm/provider.ts index 4f7b4c3a7..bbc23c884 100644 --- a/src/arch/svm/provider.ts +++ b/src/arch/svm/provider.ts @@ -1,10 +1,7 @@ import { isSolanaError as _isSolanaError } from "@solana/kit"; import { is, type, number, string } from "superstruct"; -/** - * SVM RPC provider error codes - * See https://www.quicknode.com/docs/solana/error-references - */ +// SVM RPC provider error codes. https://www.quicknode.com/docs/solana/error-references export { SOLANA_ERROR__JSON_RPC__SERVER_ERROR_BLOCK_NOT_AVAILABLE as SVM_BLOCK_NOT_AVAILABLE, SOLANA_ERROR__JSON_RPC__SERVER_ERROR_SLOT_SKIPPED as SVM_SLOT_SKIPPED, @@ -12,11 +9,7 @@ export { SOLANA_ERROR__JSON_RPC__SERVER_ERROR_SEND_TRANSACTION_PREFLIGHT_FAILURE as SVM_TRANSACTION_PREFLIGHT_FAILURE, } from "@solana/kit"; -/** - * Superstruct schema for validating SolanaError structure. - * Handles serialized errors that have lost their prototype chain. - * Uses partial validation to allow additional properties in the context object. - */ +// Structural shape check for SolanaErrors that lost their prototype (e.g. JSON round-trip). const SolanaErrorStruct = type({ name: string(), context: type({ @@ -24,10 +17,6 @@ const SolanaErrorStruct = type({ }), }); -/** - * Type definition for SolanaError structure. - * Includes common context properties for better type inference. - */ export interface SolanaErrorLike { name: string; context: { @@ -39,32 +28,11 @@ export interface SolanaErrorLike { cause?: unknown; } -/** - * Enhanced type guard to check if an error is a SolanaError. - * - * This function uses a two-tier approach: - * 1. First attempts the official instanceof-based check from @solana/kit - * 2. Falls back to structural validation using superstruct for errors that have been - * serialized/deserialized (e.g., when crossing async boundaries or through JSON parsing) - * - * @param error The error to check - * @param code Optional error code to match against context.__code - * @returns True if the error is a SolanaError (or has valid SolanaError structure) - */ +// Falls back to structural check so deserialized SolanaErrors (no prototype) still match. export function isSolanaError(error: unknown): error is SolanaErrorLike { return _isSolanaError(error) || is(error, SolanaErrorStruct); } -/** - * Structured description of a SolanaError suitable for logging. - * - * `code` and `context` mirror the underlying SolanaError's `context.__code` and `context` — - * `context` carries the rich diagnostic payload for typed errors (e.g. for - * `SVM_TRANSACTION_PREFLIGHT_FAILURE`, the `RpcSimulateTransactionResult` with `logs[]`, - * `accounts`, `returnData`, `unitsConsumed`). `cause` is recursively described when the - * underlying cause is itself a SolanaError (e.g. the `TransactionError` / - * `InstructionError` wrapped inside a preflight failure). - */ export type SolanaErrorDescription = { name: string; message?: string; @@ -74,21 +42,13 @@ export type SolanaErrorDescription = { }; /** - * Extract a structured, log-friendly description of a SolanaError. Returns `{}` for - * anything that isn't a SolanaError so callers can spread the result unconditionally. - * - * Motivation: most JSON logger formatters either (a) replace an `Error` field with its - * `.stack` string or (b) JSON.stringify it, which drops `context` and `cause` on - * SolanaErrors because those are own enumerable properties on the SolanaError instance - * but the loggers consult `.stack`/`.message` instead. This helper produces a plain - * object holding the fields you actually need to diagnose an SVM failure (program logs, - * underlying instruction error, etc.) that survives any standard serializer. + * Extract a log-friendly description of a SolanaError. Returns `{}` for non-SolanaError + * inputs so callers can spread unconditionally: + * logger.error({ at, message, error: err, ...describeSolanaError(err) }); * - * @example - * try { await sendAndConfirmSolanaTransaction(tx, provider); } - * catch (err) { - * logger.error({ at: "...", message: "...", error: err, ...describeSolanaError(err) }); - * } + * Most JSON loggers serialize Errors via `.stack`/`.message`, dropping SolanaError's + * `context` (program logs, accounts, unitsConsumed) and `cause` (wrapped TransactionError / + * InstructionError). This produces a plain object that survives any standard serializer. */ export function describeSolanaError(err: unknown): { solanaError?: SolanaErrorDescription } { if (!isSolanaError(err)) { diff --git a/test/describeSolanaError.test.ts b/test/providers/solana/describeSolanaError.test.ts similarity index 95% rename from test/describeSolanaError.test.ts rename to test/providers/solana/describeSolanaError.test.ts index c004d59f8..a181bcd07 100644 --- a/test/describeSolanaError.test.ts +++ b/test/providers/solana/describeSolanaError.test.ts @@ -1,5 +1,9 @@ -import { describeSolanaError, SVM_TRANSACTION_PREFLIGHT_FAILURE, SVM_SLOT_SKIPPED } from "../src/arch/svm/provider"; -import { expect } from "./utils"; +import { + describeSolanaError, + SVM_TRANSACTION_PREFLIGHT_FAILURE, + SVM_SLOT_SKIPPED, +} from "../../../src/arch/svm/provider"; +import { expect } from "../../utils"; describe("describeSolanaError", () => { it("returns an empty object for non-Solana errors", () => { From 149ed9c5bb6ffcbe5c2dee6210df48b25f85e741 Mon Sep 17 00:00:00 2001 From: droplet-rl <284132418+droplet-rl@users.noreply.github.com> Date: Wed, 27 May 2026 09:46:07 +0000 Subject: [PATCH 3/3] review(svm): restore pre-existing comments in provider.ts Revert the changes to JSDoc blocks for SVM error-code exports, SolanaErrorStruct, SolanaErrorLike, and isSolanaError back to their original wording; their content was not being changed by this PR. The new describeSolanaError JSDoc remains as-is (terse, motivation + usage). Addresses pxrl review feedback on #1446. Co-Authored-By: Claude Opus 4.7 --- src/arch/svm/provider.ts | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src/arch/svm/provider.ts b/src/arch/svm/provider.ts index bbc23c884..993c47487 100644 --- a/src/arch/svm/provider.ts +++ b/src/arch/svm/provider.ts @@ -1,7 +1,10 @@ import { isSolanaError as _isSolanaError } from "@solana/kit"; import { is, type, number, string } from "superstruct"; -// SVM RPC provider error codes. https://www.quicknode.com/docs/solana/error-references +/** + * SVM RPC provider error codes + * See https://www.quicknode.com/docs/solana/error-references + */ export { SOLANA_ERROR__JSON_RPC__SERVER_ERROR_BLOCK_NOT_AVAILABLE as SVM_BLOCK_NOT_AVAILABLE, SOLANA_ERROR__JSON_RPC__SERVER_ERROR_SLOT_SKIPPED as SVM_SLOT_SKIPPED, @@ -9,7 +12,11 @@ export { SOLANA_ERROR__JSON_RPC__SERVER_ERROR_SEND_TRANSACTION_PREFLIGHT_FAILURE as SVM_TRANSACTION_PREFLIGHT_FAILURE, } from "@solana/kit"; -// Structural shape check for SolanaErrors that lost their prototype (e.g. JSON round-trip). +/** + * Superstruct schema for validating SolanaError structure. + * Handles serialized errors that have lost their prototype chain. + * Uses partial validation to allow additional properties in the context object. + */ const SolanaErrorStruct = type({ name: string(), context: type({ @@ -17,6 +24,10 @@ const SolanaErrorStruct = type({ }), }); +/** + * Type definition for SolanaError structure. + * Includes common context properties for better type inference. + */ export interface SolanaErrorLike { name: string; context: { @@ -28,7 +39,18 @@ export interface SolanaErrorLike { cause?: unknown; } -// Falls back to structural check so deserialized SolanaErrors (no prototype) still match. +/** + * Enhanced type guard to check if an error is a SolanaError. + * + * This function uses a two-tier approach: + * 1. First attempts the official instanceof-based check from @solana/kit + * 2. Falls back to structural validation using superstruct for errors that have been + * serialized/deserialized (e.g., when crossing async boundaries or through JSON parsing) + * + * @param error The error to check + * @param code Optional error code to match against context.__code + * @returns True if the error is a SolanaError (or has valid SolanaError structure) + */ export function isSolanaError(error: unknown): error is SolanaErrorLike { return _isSolanaError(error) || is(error, SolanaErrorStruct); }