diff --git a/src/arch/svm/provider.ts b/src/arch/svm/provider.ts index d8dc51588..993c47487 100644 --- a/src/arch/svm/provider.ts +++ b/src/arch/svm/provider.ts @@ -54,3 +54,43 @@ export interface SolanaErrorLike { export function isSolanaError(error: unknown): error is SolanaErrorLike { return _isSolanaError(error) || is(error, SolanaErrorStruct); } + +export type SolanaErrorDescription = { + name: string; + message?: string; + code: number; + context: SolanaErrorLike["context"]; + cause?: SolanaErrorDescription | { message: string }; +}; + +/** + * 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) }); + * + * 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)) { + 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/providers/solana/describeSolanaError.test.ts b/test/providers/solana/describeSolanaError.test.ts new file mode 100644 index 000000000..a181bcd07 --- /dev/null +++ b/test/providers/solana/describeSolanaError.test.ts @@ -0,0 +1,104 @@ +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 }); + }); +});