From 9625bb76b8f1096df02664f943371b94cfd0b182 Mon Sep 17 00:00:00 2001 From: amilz <85324096+amilz@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:21:23 -0700 Subject: [PATCH 1/7] feat: add createCodedErrorClass --- .changeset/dark-turtles-try.md | 7 + .changeset/slow-queens-argue.md | 5 + packages/errors/README.md | 46 +++ .../errors/src/__tests__/coded-error-test.ts | 264 +++++++++++++ packages/errors/src/__tests__/error-test.ts | 101 ++++- .../src/__tests__/message-formatter-test.ts | 350 ++++++++---------- .../src/__typetests__/coded-error-typetest.ts | 85 +++++ packages/errors/src/coded-error.ts | 285 ++++++++++++++ packages/errors/src/error.ts | 141 ++++--- packages/errors/src/index.ts | 1 + packages/errors/src/message-formatter.ts | 49 ++- 11 files changed, 1005 insertions(+), 329 deletions(-) create mode 100644 .changeset/dark-turtles-try.md create mode 100644 .changeset/slow-queens-argue.md create mode 100644 packages/errors/src/__tests__/coded-error-test.ts create mode 100644 packages/errors/src/__typetests__/coded-error-typetest.ts create mode 100644 packages/errors/src/coded-error.ts diff --git a/.changeset/dark-turtles-try.md b/.changeset/dark-turtles-try.md new file mode 100644 index 000000000..96ed241f8 --- /dev/null +++ b/.changeset/dark-turtles-try.md @@ -0,0 +1,7 @@ +--- +'@solana/errors': patch +--- + +Refactor `SolanaError` to be produced by `createCodedErrorClass`, eliminating the dual implementation of the same pattern in this package. Behavior (dev/prod message format, instruction-error-index suffix, `cause` / deprecated-cause typing, guard narrowing, frozen context) is preserved exactly. As a side benefit, `isSolanaError`'s guard is now defensively hardened against same-name foreign errors via the factory's shared guard. + +Two additive optional fields on `CodedErrorDefinition` support SolanaError's specific behaviors and are available to any downstream consumer: `prodMessagePrefix` (override the leading token of prod-mode messages) and `messagePostProcessor` (hook for appending a suffix like `" (instruction #N)"` after template interpolation). diff --git a/.changeset/slow-queens-argue.md b/.changeset/slow-queens-argue.md new file mode 100644 index 000000000..3b32f7376 --- /dev/null +++ b/.changeset/slow-queens-argue.md @@ -0,0 +1,5 @@ +--- +'@solana/errors': minor +--- + +Add `createCodedErrorClass` — a factory that lets downstream tooling (Kora, Codama, Keychain, Solana Pay, etc.) build its own strongly-typed, numerically-coded error class backed by the same machinery as `SolanaError`, without having to register its error codes in `@solana/errors`. The returned bundle includes the new error class, a code-narrowing `isError` type guard, and a `getHumanReadableMessage` helper. diff --git a/packages/errors/README.md b/packages/errors/README.md index 92785b07c..ea4291cd6 100644 --- a/packages/errors/README.md +++ b/packages/errors/README.md @@ -85,3 +85,49 @@ try { throw e; } ``` + +## Building your own coded error class + +Downstream tools built on Kit (e.g. paymasters, wallets, programs) often want the same ergonomics as `SolanaError` — a strongly-typed class, a code-narrowing guard, dev/prod message branching — but without adding their own error codes to this package. Use `createCodedErrorClass` to mint a new error system owned by your package. + +```ts +import { createCodedErrorClass } from '@solana/errors'; + +export const KORA_ERROR__ACCOUNT_NOT_FOUND = -32050 as const; +export const KORA_ERROR__RATE_LIMIT_EXCEEDED = -32030 as const; + +type KoraErrorCode = typeof KORA_ERROR__ACCOUNT_NOT_FOUND | typeof KORA_ERROR__RATE_LIMIT_EXCEEDED; +type KoraErrorContext = { + [KORA_ERROR__ACCOUNT_NOT_FOUND]: { address: string }; + [KORA_ERROR__RATE_LIMIT_EXCEEDED]: undefined; +}; + +export const { ErrorClass: KoraError, isError: isKoraError } = createCodedErrorClass({ + messages: { + [KORA_ERROR__ACCOUNT_NOT_FOUND]: 'Account $address not found', + [KORA_ERROR__RATE_LIMIT_EXCEEDED]: 'Rate limit exceeded', + }, + name: 'KoraError', +}); + +try { + /* ... */ +} catch (e) { + if (isKoraError(e, KORA_ERROR__ACCOUNT_NOT_FOUND)) { + // `e.context.address` is typed as `string`. + displayError(`Missing account ${e.context.address}`); + } +} +``` + +The factory takes over exactly the plumbing that you would otherwise duplicate — context freezing, `cause` extraction, `$variable` interpolation, a dev/prod message switch — while leaving your error codes, messages, and context shapes in your own package. Pass `prodDecodeCommand` (e.g. `'npx @your-pkg/errors decode --'`) if you ship a CLI for decoding error codes in production bundles. + +If you want consumers to be able to write `MyError` as a type the same way they can with `SolanaError`, re-export a generic alias alongside the class: + +```ts +import type { CodedError } from '@solana/errors'; + +export type KoraError = CodedError; +``` + +The alias narrows `KoraError['context']` to the context shape for `C`, matching the narrowing you get from `isKoraError(e, code)`. diff --git a/packages/errors/src/__tests__/coded-error-test.ts b/packages/errors/src/__tests__/coded-error-test.ts new file mode 100644 index 000000000..6edd98208 --- /dev/null +++ b/packages/errors/src/__tests__/coded-error-test.ts @@ -0,0 +1,264 @@ +import '@solana/test-matchers/toBeFrozenObject'; + +import { createCodedErrorClass } from '../coded-error'; + +const CODE_WITH_CONTEXT = 1001; +const CODE_WITHOUT_CONTEXT = 1002; +const CODE_WITH_ESCAPED_DOLLAR = 1003; + +type TestCode = typeof CODE_WITH_CONTEXT | typeof CODE_WITH_ESCAPED_DOLLAR | typeof CODE_WITHOUT_CONTEXT; +type TestContext = { + [CODE_WITHOUT_CONTEXT]: undefined; + [CODE_WITH_CONTEXT]: { count: number; name: string }; + [CODE_WITH_ESCAPED_DOLLAR]: { amount: number }; +}; + +function makeBundle() { + return createCodedErrorClass({ + messages: { + [CODE_WITHOUT_CONTEXT]: 'Something went wrong', + [CODE_WITH_CONTEXT]: 'Hello $name, you have $count items', + [CODE_WITH_ESCAPED_DOLLAR]: 'Price: \\$$amount', + }, + name: 'TestError', + }); +} + +describe('createCodedErrorClass', () => { + let originalDev: boolean | undefined; + beforeEach(() => { + originalDev = (globalThis as { __DEV__?: boolean }).__DEV__; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).__DEV__ = true; + }); + afterEach(() => { + (globalThis as { __DEV__?: boolean }).__DEV__ = originalDev; + }); + + describe('errorClass', () => { + it('constructs an Error subclass with the configured name', () => { + const { ErrorClass } = makeBundle(); + const err = new ErrorClass(CODE_WITHOUT_CONTEXT); + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe('TestError'); + }); + it('freezes the context object and sets __code', () => { + const { ErrorClass } = makeBundle(); + const err = new ErrorClass(CODE_WITH_CONTEXT, { count: 3, name: 'world' }); + expect(err.context).toHaveProperty('__code', CODE_WITH_CONTEXT); + expect(err.context).toHaveProperty('name', 'world'); + expect(err.context).toHaveProperty('count', 3); + expect(err.context).toBeFrozenObject(); + }); + it('exposes a default empty-ish context for codes without context', () => { + const { ErrorClass } = makeBundle(); + const err = new ErrorClass(CODE_WITHOUT_CONTEXT); + expect(err.context).toEqual({ __code: CODE_WITHOUT_CONTEXT }); + }); + it('extracts `cause` from context into ErrorOptions', () => { + const { ErrorClass } = makeBundle(); + const rootCause = new Error('boom'); + const err = new ErrorClass(CODE_WITH_CONTEXT, { + cause: rootCause, + count: 1, + name: 'x', + } as unknown as TestContext[typeof CODE_WITH_CONTEXT]); + expect(err.cause).toBe(rootCause); + expect(err.context).not.toHaveProperty('cause'); + }); + it('interpolates $variable tokens in the message', () => { + const { ErrorClass } = makeBundle(); + const err = new ErrorClass(CODE_WITH_CONTEXT, { count: 5, name: 'alice' }); + expect(err.message).toBe('Hello alice, you have 5 items'); + }); + it('leaves unmatched variables literal', () => { + const { ErrorClass } = makeBundle(); + const err = new ErrorClass(CODE_WITH_CONTEXT, { + name: 'alice', + } as unknown as TestContext[typeof CODE_WITH_CONTEXT]); + expect(err.message).toBe('Hello alice, you have $count items'); + }); + it('honors backslash escapes for literal $ in templates', () => { + const { ErrorClass } = makeBundle(); + const err = new ErrorClass(CODE_WITH_ESCAPED_DOLLAR, { amount: 42 }); + expect(err.message).toBe('Price: $42'); + }); + }); + + describe('isError guard', () => { + it('returns true for any error produced by this bundle', () => { + const { ErrorClass, isError } = makeBundle(); + expect(isError(new ErrorClass(CODE_WITHOUT_CONTEXT))).toBe(true); + }); + it('returns false for unrelated Error instances', () => { + const { isError } = makeBundle(); + expect(isError(new Error('nope'))).toBe(false); + expect(isError({ name: 'TestError' })).toBe(false); + expect(isError(null)).toBe(false); + }); + it('narrows by code when one is supplied', () => { + const { ErrorClass, isError } = makeBundle(); + const err: unknown = new ErrorClass(CODE_WITH_CONTEXT, { count: 1, name: 'x' }); + expect(isError(err, CODE_WITH_CONTEXT)).toBe(true); + expect(isError(err, CODE_WITHOUT_CONTEXT)).toBe(false); + }); + it('rejects a foreign Error whose name was manually reassigned to match', () => { + const { isError } = makeBundle(); + const foreign = new Error('not one of ours'); + foreign.name = 'TestError'; + expect(isError(foreign)).toBe(false); + expect(isError(foreign, CODE_WITH_CONTEXT)).toBe(false); + }); + it('rejects an error whose context is missing __code even if the name matches', () => { + const { isError } = makeBundle(); + const foreign = new Error('bogus'); + foreign.name = 'TestError'; + (foreign as unknown as { context: object }).context = { foo: 'bar' }; + expect(isError(foreign)).toBe(false); + expect(isError(foreign, CODE_WITH_CONTEXT)).toBe(false); + }); + it('does not throw when narrowing on a same-name error without context', () => { + const { isError } = makeBundle(); + const foreign = new Error('bogus'); + foreign.name = 'TestError'; + expect(() => isError(foreign, CODE_WITH_CONTEXT)).not.toThrow(); + }); + it('does not cross-match between two bundles with different names', () => { + const a = makeBundle(); + const b = createCodedErrorClass({ + messages: { + [CODE_WITHOUT_CONTEXT]: 'other', + [CODE_WITH_CONTEXT]: 'other', + [CODE_WITH_ESCAPED_DOLLAR]: 'other', + }, + name: 'OtherError', + }); + const err = new a.ErrorClass(CODE_WITHOUT_CONTEXT); + expect(a.isError(err)).toBe(true); + expect(b.isError(err)).toBe(false); + }); + }); + + describe('getHumanReadableMessage', () => { + it('renders a message without constructing an error', () => { + const { getHumanReadableMessage } = makeBundle(); + expect(getHumanReadableMessage(CODE_WITH_CONTEXT, { count: 2, name: 'bob' })).toBe( + 'Hello bob, you have 2 items', + ); + }); + }); + + describe('messagePostProcessor', () => { + it('is invoked with the code, context, and interpolated message in dev mode', () => { + const postProcessor = jest.fn((_code, _ctx, message) => `${message}!!`); + const { ErrorClass } = createCodedErrorClass({ + messagePostProcessor: postProcessor, + messages: { + [CODE_WITHOUT_CONTEXT]: 'Something went wrong', + [CODE_WITH_CONTEXT]: 'Hello $name', + [CODE_WITH_ESCAPED_DOLLAR]: 'ignored', + }, + name: 'TestError', + }); + const err = new ErrorClass(CODE_WITH_CONTEXT, { count: 1, name: 'alice' }); + expect(postProcessor).toHaveBeenCalledWith( + CODE_WITH_CONTEXT, + expect.objectContaining({ name: 'alice' }), + 'Hello alice', + ); + expect(err.message).toBe('Hello alice!!'); + }); + it('is applied to the output of `getHumanReadableMessage`', () => { + const { getHumanReadableMessage } = createCodedErrorClass({ + messagePostProcessor: (_code, _ctx, message) => `${message} (suffix)`, + messages: { + [CODE_WITHOUT_CONTEXT]: 'Something went wrong', + [CODE_WITH_CONTEXT]: 'Hello $name', + [CODE_WITH_ESCAPED_DOLLAR]: 'ignored', + }, + name: 'TestError', + }); + expect(getHumanReadableMessage(CODE_WITH_CONTEXT, { count: 1, name: 'bob' })).toBe('Hello bob (suffix)'); + }); + it('is not invoked in production mode', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).__DEV__ = false; + const postProcessor = jest.fn((_code, _ctx, message) => `${message}!!`); + const { ErrorClass } = createCodedErrorClass({ + messagePostProcessor: postProcessor, + messages: { + [CODE_WITHOUT_CONTEXT]: 'ignored', + [CODE_WITH_CONTEXT]: 'ignored', + [CODE_WITH_ESCAPED_DOLLAR]: 'ignored', + }, + name: 'TestError', + }); + const err = new ErrorClass(CODE_WITHOUT_CONTEXT); + expect(postProcessor).not.toHaveBeenCalled(); + expect(err.message).toBe(`TestError #${CODE_WITHOUT_CONTEXT}`); + }); + }); + + describe('production mode messaging', () => { + beforeEach(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).__DEV__ = false; + }); + // Top-level afterEach restores __DEV__ to its pre-test value. + + it('emits a bare name + code string when no decode command is configured', () => { + const { ErrorClass } = makeBundle(); + const err = new ErrorClass(CODE_WITHOUT_CONTEXT); + expect(err.message).toBe(`TestError #${CODE_WITHOUT_CONTEXT}`); + }); + it('emits a decode hint when a decode command is configured', () => { + const { ErrorClass } = createCodedErrorClass({ + messages: { + [CODE_WITHOUT_CONTEXT]: 'ignored', + [CODE_WITH_CONTEXT]: 'ignored', + [CODE_WITH_ESCAPED_DOLLAR]: 'ignored', + }, + name: 'TestError', + prodDecodeCommand: 'npx @test/errors decode --', + }); + const err = new ErrorClass(CODE_WITHOUT_CONTEXT); + expect(err.message).toBe( + `TestError #${CODE_WITHOUT_CONTEXT}; Decode this error by running \`npx @test/errors decode -- ${CODE_WITHOUT_CONTEXT}\``, + ); + }); + it('uses `prodMessagePrefix` in place of `name` when provided', () => { + const { ErrorClass } = createCodedErrorClass({ + messages: { + [CODE_WITHOUT_CONTEXT]: 'ignored', + [CODE_WITH_CONTEXT]: 'ignored', + [CODE_WITH_ESCAPED_DOLLAR]: 'ignored', + }, + name: 'TestError', + prodDecodeCommand: 'npx @test/errors decode --', + prodMessagePrefix: 'Test error', + }); + const err = new ErrorClass(CODE_WITHOUT_CONTEXT); + expect(err.name).toBe('TestError'); + expect(err.message).toBe( + `Test error #${CODE_WITHOUT_CONTEXT}; Decode this error by running \`npx @test/errors decode -- ${CODE_WITHOUT_CONTEXT}\``, + ); + }); + it('appends an encoded context when present', () => { + const { ErrorClass } = createCodedErrorClass({ + messages: { + [CODE_WITHOUT_CONTEXT]: 'ignored', + [CODE_WITH_CONTEXT]: 'ignored', + [CODE_WITH_ESCAPED_DOLLAR]: 'ignored', + }, + name: 'TestError', + prodDecodeCommand: 'npx @test/errors decode --', + }); + const err = new ErrorClass(CODE_WITH_CONTEXT, { count: 1, name: 'x' }); + expect(err.message).toMatch( + new RegExp( + `^TestError #${CODE_WITH_CONTEXT}; Decode this error by running \`npx @test/errors decode -- ${CODE_WITH_CONTEXT} '[^']+'\`$`, + ), + ); + }); + }); +}); diff --git a/packages/errors/src/__tests__/error-test.ts b/packages/errors/src/__tests__/error-test.ts index 2528b3d92..52fba1aa5 100644 --- a/packages/errors/src/__tests__/error-test.ts +++ b/packages/errors/src/__tests__/error-test.ts @@ -1,11 +1,22 @@ import '@solana/test-matchers/toBeFrozenObject'; +import { SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN } from '../codes'; import { isSolanaError, SolanaError } from '../error'; -import { getErrorMessage } from '../message-formatter'; +import { formatMessageTemplate } from '../message-formatter'; jest.mock('../message-formatter'); describe('SolanaError', () => { + let originalDev: boolean | undefined; + beforeEach(() => { + originalDev = (globalThis as { __DEV__?: boolean }).__DEV__; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).__DEV__ = true; + jest.mocked(formatMessageTemplate).mockReturnValue('mock message'); + }); + afterEach(() => { + (globalThis as { __DEV__?: boolean }).__DEV__ = originalDev; + }); describe('given an error with context', () => { let errorWithContext: SolanaError; beforeEach(() => { @@ -24,23 +35,27 @@ describe('SolanaError', () => { it('exposes no cause', () => { expect(errorWithContext.cause).toBeUndefined(); }); - it('calls the message formatter with the code and context', () => { - expect(getErrorMessage).toHaveBeenCalledWith(123, expect.objectContaining({ foo: 'bar' })); + it('formats the message template with the context', () => { + expect(formatMessageTemplate).toHaveBeenCalledWith( + undefined, // `messages[123]` is not a real Solana error message. + expect.objectContaining({ foo: 'bar' }), + ); }); it('freezes the context object', () => { expect(errorWithContext.context).toBeFrozenObject(); }); }); describe('given an error with no context', () => { + let errorWithoutContext: SolanaError; beforeEach(() => { - new SolanaError( + errorWithoutContext = new SolanaError( // @ts-expect-error Mock error codes don't conform to `SolanaErrorCode` 123, undefined, ); }); - it('calls the message formatter with undefined context', () => { - expect(getErrorMessage).toHaveBeenCalledWith(123, undefined); + it('exposes only the `__code` on context', () => { + expect(errorWithoutContext.context).toEqual({ __code: 123 }); }); }); describe('given an error with a cause', () => { @@ -72,21 +87,68 @@ describe('SolanaError', () => { it('omits the error option from its context', () => { expect(errorWithOption.context).not.toHaveProperty(propName); }); - it('calls the message formatter with the error option omitted', () => { - expect(getErrorMessage).toHaveBeenCalledWith(123, undefined); - }); }); - it('sets its message to the output of the message formatter', async () => { - expect.assertions(1); - jest.mocked(getErrorMessage).mockReturnValue('o no'); - await jest.isolateModulesAsync(async () => { - const SolanaErrorModule = - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - await import('../error'); + it('sets its message to the output of the message formatter', () => { + jest.mocked(formatMessageTemplate).mockReturnValue('o no'); + const error456 = new SolanaError( // @ts-expect-error Mock error codes don't conform to `SolanaErrorCode` - const error456 = new SolanaErrorModule.SolanaError(456); - expect(error456).toHaveProperty('message', 'o no'); + 456, + undefined, + ); + expect(error456).toHaveProperty('message', 'o no'); + }); + describe('instruction-index suffix (dev mode)', () => { + beforeEach(() => { + jest.mocked(formatMessageTemplate).mockReturnValue('Some instruction error'); + }); + it('appends `(instruction #N)` for codes in the instruction-error range when context carries `index`', () => { + const err = new SolanaError(SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN, { errorName: 'X', index: 0 }); + expect(err.message).toBe('Some instruction error (instruction #1)'); + }); + it('uses one-based instruction numbering', () => { + const err = new SolanaError(SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN, { errorName: 'X', index: 5 }); + expect(err.message).toBe('Some instruction error (instruction #6)'); + }); + it('does not append the suffix when the context has no `index`', () => { + const err = new SolanaError(SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN, { + errorName: 'X', + } as ConstructorParameters>[1]); + expect(err.message).toBe('Some instruction error'); + }); + it('does not append the suffix to non-instruction error codes', () => { + const err = new SolanaError( + // @ts-expect-error Mock error codes don't conform to `SolanaErrorCode` + 123, + { index: 0 }, + ); + expect(err.message).toBe('Some instruction error'); + }); + }); + describe('in production mode', () => { + beforeEach(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).__DEV__ = false; + }); + // Outer `afterEach` restores `__DEV__` to its pre-test value. + it('renders the prod message prefix and decode command without context', () => { + const err = new SolanaError( + // @ts-expect-error Mock error codes don't conform to `SolanaErrorCode` + 123, + undefined, + ); + expect(err.message).toBe( + 'Solana error #123; Decode this error by running `npx @solana/errors decode -- 123`', + ); + }); + it('renders the prod message prefix, decode command, and encoded context when context is present', () => { + const err = new SolanaError( + // @ts-expect-error Mock error codes don't conform to `SolanaErrorCode` + 123, + { foo: 'bar' }, + ); + expect(err.message).toMatch( + /^Solana error #123; Decode this error by running `npx @solana\/errors decode -- 123 '[^']+'`$/, + ); }); }); }); @@ -94,6 +156,7 @@ describe('SolanaError', () => { describe('isSolanaError()', () => { let error123: SolanaError; beforeEach(() => { + jest.mocked(formatMessageTemplate).mockReturnValue('mock message'); // @ts-expect-error Mock error codes don't conform to `SolanaErrorCode` error123 = new SolanaError(123); }); diff --git a/packages/errors/src/__tests__/message-formatter-test.ts b/packages/errors/src/__tests__/message-formatter-test.ts index df96fed3c..2d13932bd 100644 --- a/packages/errors/src/__tests__/message-formatter-test.ts +++ b/packages/errors/src/__tests__/message-formatter-test.ts @@ -1,9 +1,7 @@ -import { SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN, SolanaErrorCode } from '../codes'; -import { encodeContextObject } from '../context'; -import { getErrorMessage } from '../message-formatter'; +import { SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN } from '../codes'; +import { getHumanReadableErrorMessage } from '../message-formatter'; import * as MessagesModule from '../messages'; -jest.mock('../context'); jest.mock('../messages', () => ({ get SolanaErrorMessages() { return {}; @@ -11,211 +9,153 @@ jest.mock('../messages', () => ({ __esModule: true, })); -describe('getErrorMessage', () => { - describe('in production mode', () => { - beforeEach(() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (globalThis as any).__DEV__ = false; - }); - it('renders advice on where to decode a context-less error', () => { - const message = getErrorMessage( - // @ts-expect-error Mock error codes don't conform to `SolanaErrorCode` - 123, - ); - expect(message).toBe('Solana error #123; Decode this error by running `npx @solana/errors decode -- 123`'); - }); - it('does not call the context encoder when the error has no context', () => { - getErrorMessage( - // @ts-expect-error Mock error codes don't conform to `SolanaErrorCode` - 123, - ); - expect(encodeContextObject).not.toHaveBeenCalled(); - }); - it('does not call the context encoder when the error context has no keys', () => { - const context = {}; - getErrorMessage( - // @ts-expect-error Mock error codes don't conform to `SolanaErrorCode` - 123, - context, - ); - expect(encodeContextObject).not.toHaveBeenCalled(); - }); - it('calls the context encoder with the context', () => { - const context = { foo: 'bar' }; - getErrorMessage( - // @ts-expect-error Mock error codes don't conform to `SolanaErrorCode` - 123, - context, - ); - expect(encodeContextObject).toHaveBeenCalledWith(context); - }); - it('renders advice on where to decode an error with encoded context', () => { - jest.mocked(encodeContextObject).mockReturnValue('ENCODED_CONTEXT'); - const context = { foo: 'bar' }; - const message = getErrorMessage(123 as SolanaErrorCode, context); - expect(message).toBe( - "Solana error #123; Decode this error by running `npx @solana/errors decode -- 123 'ENCODED_CONTEXT'`", - ); - }); - it('renders no encoded context in the decoding advice when the context has no keys', () => { - jest.mocked(encodeContextObject).mockReturnValue('ENCODED_CONTEXT'); - const context = {}; - const message = getErrorMessage(123 as SolanaErrorCode, context); - expect(message).toBe('Solana error #123; Decode this error by running `npx @solana/errors decode -- 123`'); +describe('getHumanReadableErrorMessage', () => { + it('renders static error messages', () => { + const messagesSpy = jest.spyOn(MessagesModule, 'SolanaErrorMessages', 'get'); + messagesSpy.mockReturnValue({ + // @ts-expect-error Mock error config doesn't conform to exported config. + 123: 'static error message', }); + const message = getHumanReadableErrorMessage( + // @ts-expect-error Mock error config doesn't conform to exported config. + 123, + ); + expect(message).toBe('static error message'); }); - describe('in dev mode', () => { - beforeEach(() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (globalThis as any).__DEV__ = true; - }); - it('renders static error messages', () => { - const messagesSpy = jest.spyOn(MessagesModule, 'SolanaErrorMessages', 'get'); - messagesSpy.mockReturnValue({ - // @ts-expect-error Mock error config doesn't conform to exported config. - 123: 'static error message', - }); - const message = getErrorMessage( - // @ts-expect-error Mock error config doesn't conform to exported config. - 123, - ); - expect(message).toBe('static error message'); - }); - it.each([ - { - expected: "Something awful happened: 'bar'. How awful!", - input: "Something $severity happened: '$foo'. How $severity!", - }, - // Literal backslashes, escaped dollar signs - { - expected: 'How \\awful\\ is the $severity?', - input: 'How \\\\$severity\\\\ is the \\$severity?', - }, - // Variable at beginning of sequence - { expected: 'awful times!', input: '$severity times!' }, - // Variable at end of sequence - { expected: "Isn't it awful?", input: "Isn't it $severity?" }, - // Variable in middle of text sequence - { expected: '~awful~', input: '~$severity~' }, - // Variable interpolation with no value in the lookup - { expected: 'Is $thing a sandwich?', input: 'Is $thing a sandwich?' }, - // Variable that has, as a substring, some other value in the lookup - { expected: '$fool', input: '$fool' }, - // Trick for butting a variable up against regular text - { expected: 'barl', input: '$foo\\l' }, - // Escaped variable marker - { expected: "It's the $severity, ya hear?", input: "It's the \\$severity, ya hear?" }, - // Single dollar sign - { expected: ' $ ', input: ' $ ' }, - // Single dollar sign at start - { expected: '$ ', input: '$ ' }, - // Single dollar sign at end - { expected: ' $', input: ' $' }, - // Double dollar sign with legitimate variable name - { expected: ' $bar ', input: ' $$foo ' }, - // Double dollar sign with legitimate variable name at start - { expected: '$bar ', input: '$$foo ' }, - // Double dollar sign with legitimate variable name at end - { expected: ' $bar', input: ' $$foo' }, - // Single escape sequence - { expected: ' ', input: ' \\ ' }, - // Single escape sequence at start - { expected: ' ', input: '\\ ' }, - // Single escape sequence at end - { expected: ' ', input: ' \\' }, - // Double escape sequence - { expected: ' \\ ', input: ' \\\\ ' }, - // Double escape sequence at start - { expected: '\\ ', input: '\\\\ ' }, - // Double escape sequence at end - { expected: ' \\', input: ' \\\\' }, - // Just text - { expected: 'Some unencumbered text.', input: 'Some unencumbered text.' }, - // Empty string - { expected: '', input: '' }, - ])('interpolates variables into the error message format string `"$input"`', ({ input, expected }) => { - const messagesSpy = jest.spyOn(MessagesModule, 'SolanaErrorMessages', 'get'); - messagesSpy.mockReturnValue({ - // @ts-expect-error Mock error config doesn't conform to exported config. - 123: input, - }); - const message = getErrorMessage( - // @ts-expect-error Mock error context doesn't conform to exported context. - 123, - { foo: 'bar', severity: 'awful' }, - ); - expect(message).toBe(expected); - }); - it('interpolates a Uint8Array variable into a error message format string', () => { - const messagesSpy = jest.spyOn(MessagesModule, 'SolanaErrorMessages', 'get'); - messagesSpy.mockReturnValue({ - // @ts-expect-error Mock error config doesn't conform to exported config. - 123: 'Here is some data: $data', - }); - const message = getErrorMessage( - // @ts-expect-error Mock error context doesn't conform to exported context. - 123, - { data: new Uint8Array([1, 2, 3, 4]) }, - ); - expect(message).toBe('Here is some data: 1,2,3,4'); - }); - it('interpolates an undefined variable into a error message format string', () => { - const messagesSpy = jest.spyOn(MessagesModule, 'SolanaErrorMessages', 'get'); - messagesSpy.mockReturnValue({ - // @ts-expect-error Mock error config doesn't conform to exported config. - 123: 'Here is a variable: $variable', - }); - const message = getErrorMessage( - // @ts-expect-error Mock error context doesn't conform to exported context. - 123, - { variable: undefined }, - ); - expect(message).toBe('Here is a variable: undefined'); - }); - it('appends the instruction number to instruction error messages', () => { - const messagesSpy = jest.spyOn(MessagesModule, 'SolanaErrorMessages', 'get'); + it.each([ + { + expected: "Something awful happened: 'bar'. How awful!", + input: "Something $severity happened: '$foo'. How $severity!", + }, + // Literal backslashes, escaped dollar signs + { + expected: 'How \\awful\\ is the $severity?', + input: 'How \\\\$severity\\\\ is the \\$severity?', + }, + // Variable at beginning of sequence + { expected: 'awful times!', input: '$severity times!' }, + // Variable at end of sequence + { expected: "Isn't it awful?", input: "Isn't it $severity?" }, + // Variable in middle of text sequence + { expected: '~awful~', input: '~$severity~' }, + // Variable interpolation with no value in the lookup + { expected: 'Is $thing a sandwich?', input: 'Is $thing a sandwich?' }, + // Variable that has, as a substring, some other value in the lookup + { expected: '$fool', input: '$fool' }, + // Trick for butting a variable up against regular text + { expected: 'barl', input: '$foo\\l' }, + // Escaped variable marker + { expected: "It's the $severity, ya hear?", input: "It's the \\$severity, ya hear?" }, + // Single dollar sign + { expected: ' $ ', input: ' $ ' }, + // Single dollar sign at start + { expected: '$ ', input: '$ ' }, + // Single dollar sign at end + { expected: ' $', input: ' $' }, + // Double dollar sign with legitimate variable name + { expected: ' $bar ', input: ' $$foo ' }, + // Double dollar sign with legitimate variable name at start + { expected: '$bar ', input: '$$foo ' }, + // Double dollar sign with legitimate variable name at end + { expected: ' $bar', input: ' $$foo' }, + // Single escape sequence + { expected: ' ', input: ' \\ ' }, + // Single escape sequence at start + { expected: ' ', input: '\\ ' }, + // Single escape sequence at end + { expected: ' ', input: ' \\' }, + // Double escape sequence + { expected: ' \\ ', input: ' \\\\ ' }, + // Double escape sequence at start + { expected: '\\ ', input: '\\\\ ' }, + // Double escape sequence at end + { expected: ' \\', input: ' \\\\' }, + // Just text + { expected: 'Some unencumbered text.', input: 'Some unencumbered text.' }, + // Empty string + { expected: '', input: '' }, + ])('interpolates variables into the error message format string `"$input"`', ({ input, expected }) => { + const messagesSpy = jest.spyOn(MessagesModule, 'SolanaErrorMessages', 'get'); + messagesSpy.mockReturnValue({ // @ts-expect-error Mock error config doesn't conform to exported config. - messagesSpy.mockReturnValue({ - [SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN]: 'Some instruction error', - }); - const message = getErrorMessage(SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN, { index: 0 }); - expect(message).toBe('Some instruction error (instruction #1)'); - }); - it('uses one-based instruction numbering', () => { - const messagesSpy = jest.spyOn(MessagesModule, 'SolanaErrorMessages', 'get'); + 123: input, + }); + const message = getHumanReadableErrorMessage( + // @ts-expect-error Mock error context doesn't conform to exported context. + 123, + { foo: 'bar', severity: 'awful' }, + ); + expect(message).toBe(expected); + }); + it('interpolates a Uint8Array variable into a error message format string', () => { + const messagesSpy = jest.spyOn(MessagesModule, 'SolanaErrorMessages', 'get'); + messagesSpy.mockReturnValue({ // @ts-expect-error Mock error config doesn't conform to exported config. - messagesSpy.mockReturnValue({ - [SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN]: 'Some instruction error', - }); - const message = getErrorMessage(SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN, { index: 5 }); - expect(message).toBe('Some instruction error (instruction #6)'); - }); - it('appends the instruction number to error codes at the end of the instruction error range', () => { - const lastInstructionErrorCode = SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN + 999; - const messagesSpy = jest.spyOn(MessagesModule, 'SolanaErrorMessages', 'get'); + 123: 'Here is some data: $data', + }); + const message = getHumanReadableErrorMessage( + // @ts-expect-error Mock error context doesn't conform to exported context. + 123, + { data: new Uint8Array([1, 2, 3, 4]) }, + ); + expect(message).toBe('Here is some data: 1,2,3,4'); + }); + it('interpolates an undefined variable into a error message format string', () => { + const messagesSpy = jest.spyOn(MessagesModule, 'SolanaErrorMessages', 'get'); + messagesSpy.mockReturnValue({ // @ts-expect-error Mock error config doesn't conform to exported config. - messagesSpy.mockReturnValue({ - [lastInstructionErrorCode]: 'Some instruction error', - }); - const message = getErrorMessage( - // @ts-expect-error Mock error code doesn't conform to exported config. - lastInstructionErrorCode, - { index: 2 }, - ); - expect(message).toBe('Some instruction error (instruction #3)'); - }); - it('does not append the instruction number to non-instruction error messages', () => { - const messagesSpy = jest.spyOn(MessagesModule, 'SolanaErrorMessages', 'get'); - messagesSpy.mockReturnValue({ - // @ts-expect-error Mock error config doesn't conform to exported config. - 123: 'some other error', - }); - const message = getErrorMessage( - // @ts-expect-error Mock error context doesn't conform to exported context. - 123, - { index: 0 }, - ); - expect(message).toBe('some other error'); - }); + 123: 'Here is a variable: $variable', + }); + const message = getHumanReadableErrorMessage( + // @ts-expect-error Mock error context doesn't conform to exported context. + 123, + { variable: undefined }, + ); + expect(message).toBe('Here is a variable: undefined'); + }); + it('appends the instruction number to instruction error messages', () => { + const messagesSpy = jest.spyOn(MessagesModule, 'SolanaErrorMessages', 'get'); + // @ts-expect-error Mock error config doesn't conform to exported config. + messagesSpy.mockReturnValue({ + [SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN]: 'Some instruction error', + }); + const message = getHumanReadableErrorMessage(SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN, { index: 0 }); + expect(message).toBe('Some instruction error (instruction #1)'); + }); + it('uses one-based instruction numbering', () => { + const messagesSpy = jest.spyOn(MessagesModule, 'SolanaErrorMessages', 'get'); + // @ts-expect-error Mock error config doesn't conform to exported config. + messagesSpy.mockReturnValue({ + [SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN]: 'Some instruction error', + }); + const message = getHumanReadableErrorMessage(SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN, { index: 5 }); + expect(message).toBe('Some instruction error (instruction #6)'); + }); + it('appends the instruction number to error codes at the end of the instruction error range', () => { + const lastInstructionErrorCode = SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN + 999; + const messagesSpy = jest.spyOn(MessagesModule, 'SolanaErrorMessages', 'get'); + // @ts-expect-error Mock error config doesn't conform to exported config. + messagesSpy.mockReturnValue({ + [lastInstructionErrorCode]: 'Some instruction error', + }); + const message = getHumanReadableErrorMessage( + // @ts-expect-error Mock error code doesn't conform to exported config. + lastInstructionErrorCode, + { index: 2 }, + ); + expect(message).toBe('Some instruction error (instruction #3)'); + }); + it('does not append the instruction number to non-instruction error messages', () => { + const messagesSpy = jest.spyOn(MessagesModule, 'SolanaErrorMessages', 'get'); + messagesSpy.mockReturnValue({ + // @ts-expect-error Mock error config doesn't conform to exported config. + 123: 'some other error', + }); + const message = getHumanReadableErrorMessage( + // @ts-expect-error Mock error context doesn't conform to exported context. + 123, + { index: 0 }, + ); + expect(message).toBe('some other error'); }); }); diff --git a/packages/errors/src/__typetests__/coded-error-typetest.ts b/packages/errors/src/__typetests__/coded-error-typetest.ts new file mode 100644 index 000000000..fd829a3e3 --- /dev/null +++ b/packages/errors/src/__typetests__/coded-error-typetest.ts @@ -0,0 +1,85 @@ +import { CodedError, createCodedErrorClass } from '../coded-error'; + +const CODE_WITH_CONTEXT = 1001 as const; +const CODE_WITHOUT_CONTEXT = 1002 as const; + +type TestCode = typeof CODE_WITH_CONTEXT | typeof CODE_WITHOUT_CONTEXT; +type TestContext = { + [CODE_WITHOUT_CONTEXT]: undefined; + [CODE_WITH_CONTEXT]: { count: number; name: string }; +}; + +const { + ErrorClass: TestError, + isError: isTestError, + getHumanReadableMessage, +} = createCodedErrorClass({ + messages: { + [CODE_WITHOUT_CONTEXT]: 'no context', + [CODE_WITH_CONTEXT]: 'hello $name ($count)', + }, + name: 'TestError', +}); + +// Constructor arity: codes without context accept zero or one (ErrorOptions) arg. +new TestError(CODE_WITHOUT_CONTEXT); +new TestError(CODE_WITHOUT_CONTEXT, { cause: new Error('x') }); + +// Constructor arity: codes WITH context require the context arg. +new TestError(CODE_WITH_CONTEXT, { count: 1, name: 'a' }); +// @ts-expect-error Missing required context. +new TestError(CODE_WITH_CONTEXT); +// @ts-expect-error Wrong context shape. +new TestError(CODE_WITH_CONTEXT, { wrong: true }); +// @ts-expect-error Missing required `name`. +new TestError(CODE_WITH_CONTEXT, { count: 1 }); + +// `context.__code` narrows to the specific code on an instance. +{ + const err = new TestError(CODE_WITH_CONTEXT, { count: 1, name: 'a' }); + err.context.__code satisfies typeof CODE_WITH_CONTEXT; + // @ts-expect-error Wrong code. + err.context.__code satisfies typeof CODE_WITHOUT_CONTEXT; + err.context.name satisfies string; + err.context.count satisfies number; + // @ts-expect-error No such property on this code's context. + void err.context.nope; +} + +// `isTestError` code-less overload narrows to the union form. +{ + const e: unknown = null; + if (isTestError(e)) { + e.context.__code satisfies TestCode; + } +} + +// `isTestError` code-specific overload narrows context to that code's shape. +{ + const e: unknown = null; + if (isTestError(e, CODE_WITH_CONTEXT)) { + e.context.name satisfies string; + e.context.count satisfies number; + // @ts-expect-error Not a property of CODE_WITH_CONTEXT's context. + void e.context.other; + } + if (isTestError(e, CODE_WITHOUT_CONTEXT)) { + e.context.__code satisfies typeof CODE_WITHOUT_CONTEXT; + } +} + +// `getHumanReadableMessage` arity: mirrors constructor requirements. +getHumanReadableMessage(CODE_WITHOUT_CONTEXT); +getHumanReadableMessage(CODE_WITH_CONTEXT, { count: 1, name: 'a' }); +// @ts-expect-error Missing required context. +getHumanReadableMessage(CODE_WITH_CONTEXT); +// @ts-expect-error Wrong context shape. +getHumanReadableMessage(CODE_WITH_CONTEXT, { wrong: true }); + +// The exported `CodedError` type can be used to write Kit-style class-generic aliases. +type TestErrorAlias = CodedError; +{ + const e = new TestError(CODE_WITH_CONTEXT, { count: 1, name: 'a' }); + e satisfies TestErrorAlias; + e satisfies TestErrorAlias; +} diff --git a/packages/errors/src/coded-error.ts b/packages/errors/src/coded-error.ts new file mode 100644 index 000000000..43ae45796 --- /dev/null +++ b/packages/errors/src/coded-error.ts @@ -0,0 +1,285 @@ +import { encodeContextObject } from './context'; +import { formatMessageTemplate } from './message-formatter'; + +/** + * A map of error code → context object shape. Use `undefined` for codes that carry no context. + * + * @example + * ```ts + * type KoraErrorContext = { + * [KORA_ERROR__ACCOUNT_NOT_FOUND]: { address: string }; + * [KORA_ERROR__RATE_LIMIT_EXCEEDED]: undefined; + * }; + * ``` + */ +export type CodedErrorContextMap = { + [P in TCode]: object | undefined; +}; + +/** + * The shape of an instance produced by a class created with {@link createCodedErrorClass}. + * + * @typeParam TCode Union of all numeric error codes the class can throw. + * @typeParam TContextMap Mapping from error code to its context shape. + * @typeParam C The specific error code this instance carries (narrowed by the guard). + */ +type CodedErrorCodedContext> = { + [P in TCode]: Readonly<{ __code: P }> & (TContextMap[P] extends undefined ? object : NonNullable); +}; + +export interface CodedError< + TCode extends number, + TContextMap extends CodedErrorContextMap, + C extends TCode = TCode, +> extends Error { + readonly cause?: unknown; + readonly context: CodedErrorCodedContext[C]; + readonly name: string; +} + +/** + * Definition accepted by {@link createCodedErrorClass}. + */ +export interface CodedErrorDefinition { + /** + * Optional hook called on the fully-interpolated dev-mode message immediately before it is + * passed to {@link Error}'s constructor. Use this to append a suffix based on context (for + * example, {@link SolanaError} uses it to append `" (instruction #N)"` to messages for error + * codes in the instruction-error range when the context carries an `index` key). + * + * Not invoked in production mode. + */ + messagePostProcessor?: (code: C, context: object, message: string) => string; + /** + * Human-readable message templates keyed by code. Use `$variable` tokens to interpolate + * values from an error's context; escape a literal `$` with `\\$`. + * + * Templates are only rendered when `__DEV__ === true`. In production builds the factory + * ignores this map and emits the short-form message described by + * {@link CodedErrorDefinition.prodDecodeCommand}. Unlike {@link SolanaError}'s own messages + * map, the factory does not arrange for these strings to be stripped from production + * bundles — consumers that want that must drop the templates in their own build pipeline. + */ + messages: Readonly>; + /** + * The class name (and the value written to `error.name`). Downstream guards identify + * instances by matching this exact string, so pick something unique (e.g. `'KoraError'`, + * `'CodamaError'`). + */ + name: string; + /** + * Optional shell command that recovers a human-readable message from an error code in + * production builds. If supplied, production error messages take the form: + * + * "{prefix} #{code}; Decode this error by running `{prodDecodeCommand} {code} '{encodedContext}'`" + * + * where `{prefix}` is {@link CodedErrorDefinition.prodMessagePrefix} (falling back to + * {@link CodedErrorDefinition.name}). If omitted, production messages are simply `"{prefix} #{code}"`. + * + * @example + * ```ts + * prodDecodeCommand: 'npx @kora/errors decode --' + * ``` + */ + prodDecodeCommand?: string; + /** + * Optional override for the leading token of production-mode error messages. Useful when a + * downstream consumer wants a different prefix from the class `name` — e.g. {@link SolanaError} + * uses `name: 'SolanaError'` (for `error.name` / `instanceof` identification) but emits + * `"Solana error #N; ..."` as the prod message for wire-format compatibility with older + * decoders. Defaults to {@link CodedErrorDefinition.name}. + */ + prodMessagePrefix?: string; +} + +type ConstructorArgsFor< + TCode extends number, + TContextMap extends CodedErrorContextMap, + C extends TCode, +> = TContextMap[C] extends undefined + ? [code: C, errorOptions?: ErrorOptions | undefined] + : [code: C, contextAndErrorOptions: TContextMap[C] & (ErrorOptions | undefined)]; + +type FormatterArgsFor< + TCode extends number, + TContextMap extends CodedErrorContextMap, + C extends TCode, +> = TContextMap[C] extends undefined ? [code: C] : [code: C, context: TContextMap[C]]; + +/** + * The constructor produced by {@link createCodedErrorClass}. Instantiate with a code and, if the + * code's context shape is non-`undefined`, the matching context object. A `cause` property on + * that object is extracted and forwarded as {@link ErrorOptions.cause}. + */ +export interface CodedErrorConstructor> { + new (...args: ConstructorArgsFor): CodedError; +} + +/** + * A code-narrowing type guard returned by {@link createCodedErrorClass}. When the `code` argument + * is supplied and the input is an error produced by the factory, TypeScript refines the error's + * `context` property to the shape associated with that code. + */ +export interface CodedErrorGuard> { + (e: unknown, code: C): e is CodedError; + (e: unknown): e is CodedError; +} + +/** + * The value returned by {@link createCodedErrorClass} — a tuple-style object containing the new + * error class, a matching type guard, and a standalone message formatter. + */ +export interface CodedErrorClassBundle> { + /** + * The constructor for the new error class. See {@link CodedErrorConstructor}. + */ + ErrorClass: CodedErrorConstructor; + /** + * Formats the human-readable message for a code/context pair without constructing an error. + * Handy for logging, or for custom `cause` chains where you want the message string alone. + */ + getHumanReadableMessage: (...args: FormatterArgsFor) => string; + /** + * A code-narrowing type guard. See {@link CodedErrorGuard}. + */ + isError: CodedErrorGuard; +} + +/** + * Creates a coded error system — an error class, matching type guard, and message formatter — + * modeled after {@link SolanaError}. + * + * Use this when building tooling around Kit (paymasters, wallets, program clients, etc.) that + * needs its own strongly-typed, numerically-coded errors, but where introducing every domain's + * codes into `@solana/errors` would balloon its scope. Codes, messages, and context shapes stay + * in the downstream package; this helper owns only the mechanical plumbing. + * + * @typeParam TCode Union of all numeric error codes the class can throw. + * @typeParam TContextMap Mapping from error code to its context shape (`undefined` for codes + * that carry no context). + * + * @example + * ```ts + * import { createCodedErrorClass } from '@solana/errors'; + * + * export const KORA_ERROR__ACCOUNT_NOT_FOUND = -32050 as const; + * export const KORA_ERROR__RATE_LIMIT_EXCEEDED = -32030 as const; + * + * type KoraErrorCode = typeof KORA_ERROR__ACCOUNT_NOT_FOUND | typeof KORA_ERROR__RATE_LIMIT_EXCEEDED; + * type KoraErrorContext = { + * [KORA_ERROR__ACCOUNT_NOT_FOUND]: { address: string }; + * [KORA_ERROR__RATE_LIMIT_EXCEEDED]: undefined; + * }; + * + * export const { ErrorClass: KoraError, isError: isKoraError } = createCodedErrorClass< + * KoraErrorCode, + * KoraErrorContext + * >({ + * messages: { + * [KORA_ERROR__ACCOUNT_NOT_FOUND]: 'Account $address not found', + * [KORA_ERROR__RATE_LIMIT_EXCEEDED]: 'Rate limit exceeded', + * }, + * name: 'KoraError', + * }); + * + * try { + * // ... + * } catch (e) { + * if (isKoraError(e, KORA_ERROR__ACCOUNT_NOT_FOUND)) { + * // `e.context.address` is now typed as `string`. + * console.error(`Missing account ${e.context.address}`); + * } + * } + * ``` + * + * @see {@link SolanaError} — the reference implementation this factory mirrors. + */ +export function createCodedErrorClass>( + definition: CodedErrorDefinition, +): CodedErrorClassBundle { + const { messagePostProcessor, messages, name, prodDecodeCommand, prodMessagePrefix } = definition; + const prefix = prodMessagePrefix ?? name; + + function getHumanReadableMessage(...args: FormatterArgsFor): string { + const [code, context] = args; + const ctx = context ?? {}; + const rendered = formatMessageTemplate(messages[code], ctx); + return messagePostProcessor ? messagePostProcessor(code, ctx, rendered) : rendered; + } + + function getMessage(code: C, context: Record): string { + if (__DEV__) { + const rendered = formatMessageTemplate(messages[code], context); + return messagePostProcessor ? messagePostProcessor(code, context, rendered) : rendered; + } + let message = `${prefix} #${code}`; + if (prodDecodeCommand !== undefined) { + message += `; Decode this error by running \`${prodDecodeCommand} ${code}`; + if (Object.keys(context).length) { + /** + * DANGER: Be sure that the shell command is escaped in such a way that makes it + * impossible for someone to craft malicious context values that would + * result in an exploit against anyone who blindly copy/pastes it into + * their terminal. + */ + message += ` '${encodeContextObject(context)}'`; + } + message += '`'; + } + return message; + } + + class CodedErrorImpl extends Error { + readonly context: Readonly & { __code: TCode }>; + constructor(code: TCode, contextAndErrorOptions?: ErrorOptions & Record) { + let context: Record | undefined; + let errorOptions: ErrorOptions | undefined; + if (contextAndErrorOptions) { + Object.entries(Object.getOwnPropertyDescriptors(contextAndErrorOptions)).forEach( + ([propName, descriptor]) => { + // If the `ErrorOptions` type ever changes, update this code. + if (propName === 'cause') { + errorOptions = { cause: descriptor.value }; + } else { + if (context === undefined) { + context = { __code: code }; + } + Object.defineProperty(context, propName, descriptor); + } + }, + ); + } + const message = getMessage(code, context ?? {}); + super(message, errorOptions); + this.context = Object.freeze(context === undefined ? { __code: code } : context) as Readonly< + Record & { __code: TCode } + >; + // This is necessary so that the guard can identify instances without having to import + // the class for use in an `instanceof` check. + this.name = name; + } + } + + const isError = ((e: unknown, code?: TCode) => { + if (!(e instanceof Error) || e.name !== name) { + return false; + } + // A foreign `Error` could share our `name` (coincidence, duplicate install, manual + // reassignment of `error.name`). Require a frozen `context.__code` matching our + // convention before returning true, so we never narrow onto an unrelated object. + const { context } = e as { context?: unknown }; + if (typeof context !== 'object' || context === null || !('__code' in context)) { + return false; + } + if (code === undefined) { + return true; + } + return (context as { __code: unknown }).__code === code; + }) as CodedErrorGuard; + + return { + ErrorClass: CodedErrorImpl as unknown as CodedErrorConstructor, + getHumanReadableMessage, + isError, + }; +} diff --git a/packages/errors/src/error.ts b/packages/errors/src/error.ts index f169cc610..e4fa40865 100644 --- a/packages/errors/src/error.ts +++ b/packages/errors/src/error.ts @@ -1,6 +1,65 @@ -import { SolanaErrorCode, SolanaErrorCodeWithCause, SolanaErrorCodeWithDeprecatedCause } from './codes'; +import { type CodedError, createCodedErrorClass } from './coded-error'; +import { + SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN, + SolanaErrorCode, + SolanaErrorCodeWithCause, + SolanaErrorCodeWithDeprecatedCause, +} from './codes'; import { SolanaErrorContext } from './context'; -import { getErrorMessage } from './message-formatter'; +import { SolanaErrorMessages } from './messages'; + +const INSTRUCTION_ERROR_RANGE_SIZE = 1000; + +type SolanaErrorContextMap = { [P in SolanaErrorCode]: SolanaErrorContext[P] }; + +const { ErrorClass, isError } = createCodedErrorClass({ + messagePostProcessor: (code, context, message) => { + if ( + code >= SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN && + code < SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN + INSTRUCTION_ERROR_RANGE_SIZE && + 'index' in context + ) { + return message + ` (instruction #${(context as { index: number }).index + 1})`; + } + return message; + }, + messages: SolanaErrorMessages, + name: 'SolanaError', + prodDecodeCommand: 'npx @solana/errors decode --', + prodMessagePrefix: 'Solana error', +}); + +type SolanaErrorConstructor = { + new ( + ...args: SolanaErrorContext[TErrorCode] extends undefined + ? [code: TErrorCode, errorOptions?: ErrorOptions | undefined] + : [code: TErrorCode, contextAndErrorOptions: SolanaErrorContext[TErrorCode] & (ErrorOptions | undefined)] + ): SolanaError; +}; + +/** + * Encapsulates an error's stacktrace, a Solana-specific numeric code that indicates what went + * wrong, and optional context if the type of error indicated by the code supports it. + */ +export const SolanaError = ErrorClass as unknown as SolanaErrorConstructor; + +/** + * The type of an instance of {@link SolanaError}. Narrows `context` to the shape associated with + * the supplied error code, and narrows `cause` to {@link SolanaError} for error codes in + * {@link SolanaErrorCodeWithCause} and to `unknown` otherwise. + */ +export type SolanaError = Omit< + CodedError, + 'cause' +> & { + /** + * Indicates the root cause of this {@link SolanaError}, if any. + * + * For example, a transaction error might have an instruction error as its root cause. In this + * case, you will be able to access the instruction error on the transaction error as `cause`. + */ + readonly cause?: TErrorCode extends SolanaErrorCodeWithCause ? SolanaError : unknown; +}; /** * A variant of {@link SolanaError} where the `cause` property is deprecated. @@ -69,80 +128,6 @@ export function isSolanaError( e: unknown, code?: TErrorCode, ): e is SolanaError; -export function isSolanaError( - e: unknown, - /** - * When supplied, this function will require that the input is a {@link SolanaError} _and_ that - * its error code is exactly this value. - */ - code?: TErrorCode, -): e is SolanaError { - const isSolanaError = e instanceof Error && e.name === 'SolanaError'; - if (isSolanaError) { - if (code !== undefined) { - return (e as SolanaError).context.__code === code; - } - return true; - } - return false; -} - -type SolanaErrorCodedContext = { - [P in SolanaErrorCode]: Readonly<{ - __code: P; - }> & - (SolanaErrorContext[P] extends undefined ? object : SolanaErrorContext[P]); -}; - -/** - * Encapsulates an error's stacktrace, a Solana-specific numeric code that indicates what went - * wrong, and optional context if the type of error indicated by the code supports it. - */ -export class SolanaError extends Error { - /** - * Indicates the root cause of this {@link SolanaError}, if any. - * - * For example, a transaction error might have an instruction error as its root cause. In this - * case, you will be able to access the instruction error on the transaction error as `cause`. - */ - readonly cause?: TErrorCode extends SolanaErrorCodeWithCause ? SolanaError : unknown = this.cause; - /** - * Contains context that can assist in understanding or recovering from a {@link SolanaError}. - */ - readonly context: SolanaErrorCodedContext[TErrorCode]; - constructor( - ...[code, contextAndErrorOptions]: SolanaErrorContext[TErrorCode] extends undefined - ? [code: TErrorCode, errorOptions?: ErrorOptions | undefined] - : [code: TErrorCode, contextAndErrorOptions: SolanaErrorContext[TErrorCode] & (ErrorOptions | undefined)] - ) { - let context: SolanaErrorContext[TErrorCode] | undefined; - let errorOptions: ErrorOptions | undefined; - if (contextAndErrorOptions) { - Object.entries(Object.getOwnPropertyDescriptors(contextAndErrorOptions)).forEach(([name, descriptor]) => { - // If the `ErrorOptions` type ever changes, update this code. - if (name === 'cause') { - errorOptions = { cause: descriptor.value }; - } else { - if (context === undefined) { - context = { - __code: code, - } as unknown as SolanaErrorContext[TErrorCode]; - } - Object.defineProperty(context, name, descriptor); - } - }); - } - const message = getErrorMessage(code, context); - super(message, errorOptions); - this.context = Object.freeze( - context === undefined - ? { - __code: code, - } - : context, - ) as SolanaErrorCodedContext[TErrorCode]; - // This is necessary so that `isSolanaError()` can identify a `SolanaError` without having - // to import the class for use in an `instanceof` check. - this.name = 'SolanaError'; - } +export function isSolanaError(e: unknown, code?: SolanaErrorCode): boolean { + return code === undefined ? isError(e) : isError(e, code); } diff --git a/packages/errors/src/index.ts b/packages/errors/src/index.ts index f0be368b4..1529116f2 100644 --- a/packages/errors/src/index.ts +++ b/packages/errors/src/index.ts @@ -63,6 +63,7 @@ * * @packageDocumentation */ +export * from './coded-error'; export * from './codes'; export * from './error'; export * from './instruction-error'; diff --git a/packages/errors/src/message-formatter.ts b/packages/errors/src/message-formatter.ts index f9aa02405..70c63e0a8 100644 --- a/packages/errors/src/message-formatter.ts +++ b/packages/errors/src/message-formatter.ts @@ -1,5 +1,4 @@ import { SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN, SolanaErrorCode } from './codes'; -import { encodeContextObject } from './context'; import { SolanaErrorMessages } from './messages'; const INSTRUCTION_ERROR_RANGE_SIZE = 1000; @@ -16,11 +15,20 @@ type State = Readonly<{ const START_INDEX = 'i'; const TYPE = 't'; -export function getHumanReadableErrorMessage( - code: TErrorCode, - context: object = {}, -): string { - const messageFormatString = SolanaErrorMessages[code]; +/** + * Interpolates `$variable` tokens in a message template with values from a context object. + * + * Tokens that do not have a matching key in the context are rendered literally (e.g. `$foo` stays + * `$foo`). Use a backslash to escape a `$` that should not be treated as a variable (e.g. `\$foo`). + * + * This is the low-level formatter shared by {@link getHumanReadableErrorMessage} (which layers on + * the {@link SolanaErrorMessages} lookup and the instruction-error-index suffix) and by + * {@link createCodedErrorClass} (which layers on a consumer-provided message map). + * + * @param messageFormatString The message template containing `$variable` tokens. + * @param context An object whose keys correspond to the variables in the template. + */ +export function formatMessageTemplate(messageFormatString: string, context: object = {}): string { if (messageFormatString.length === 0) { return ''; } @@ -83,7 +91,14 @@ export function getHumanReadableErrorMessage } }); commitStateUpTo(); - let message = fragments.join(''); + return fragments.join(''); +} + +export function getHumanReadableErrorMessage( + code: TErrorCode, + context: object = {}, +): string { + let message = formatMessageTemplate(SolanaErrorMessages[code], context); if ( code >= SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN && code < SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN + INSTRUCTION_ERROR_RANGE_SIZE && @@ -93,23 +108,3 @@ export function getHumanReadableErrorMessage } return message; } - -export function getErrorMessage( - code: TErrorCode, - context: Record = {}, -): string { - if (__DEV__) { - return getHumanReadableErrorMessage(code, context); - } else { - let decodingAdviceMessage = `Solana error #${code}; Decode this error by running \`npx @solana/errors decode -- ${code}`; - if (Object.keys(context).length) { - /** - * DANGER: Be sure that the shell command is escaped in such a way that makes it - * impossible for someone to craft malicious context values that would result in - * an exploit against anyone who bindly copy/pastes it into their terminal. - */ - decodingAdviceMessage += ` '${encodeContextObject(context)}'`; - } - return `${decodingAdviceMessage}\``; - } -} From f5a396392dd9208870e36d5de648d8619bd51eb9 Mon Sep 17 00:00:00 2001 From: amilz <85324096+amilz@users.noreply.github.com> Date: Mon, 27 Apr 2026 08:39:07 -0700 Subject: [PATCH 2/7] chore: minor cleanup --- packages/errors/README.md | 2 + .../errors/src/__tests__/coded-error-test.ts | 79 +++++++++++++++++++ packages/errors/src/coded-error.ts | 49 ++++++++---- packages/errors/src/message-formatter.ts | 15 ++-- 4 files changed, 122 insertions(+), 23 deletions(-) diff --git a/packages/errors/README.md b/packages/errors/README.md index ea4291cd6..21d8e2ddf 100644 --- a/packages/errors/README.md +++ b/packages/errors/README.md @@ -122,6 +122,8 @@ try { The factory takes over exactly the plumbing that you would otherwise duplicate — context freezing, `cause` extraction, `$variable` interpolation, a dev/prod message switch — while leaving your error codes, messages, and context shapes in your own package. Pass `prodDecodeCommand` (e.g. `'npx @your-pkg/errors decode --'`) if you ship a CLI for decoding error codes in production bundles. +The factory never reads the `messages` map when `__DEV__` is `false`. If you ship a bundler-targeted build where `__DEV__` is statically replaced with `false`, a downstream bundler can DCE the templates. If you want to guarantee the templates drop out of your own production bundles, gate the reference at the call site — e.g. `messages: __DEV__ ? MyMessages : ({} as typeof MyMessages)` — so the templates module is unreachable under a static `__DEV__ === false` replacement. + If you want consumers to be able to write `MyError` as a type the same way they can with `SolanaError`, re-export a generic alias alongside the class: ```ts diff --git a/packages/errors/src/__tests__/coded-error-test.ts b/packages/errors/src/__tests__/coded-error-test.ts index 6edd98208..61adf7b22 100644 --- a/packages/errors/src/__tests__/coded-error-test.ts +++ b/packages/errors/src/__tests__/coded-error-test.ts @@ -146,6 +146,85 @@ describe('createCodedErrorClass', () => { 'Hello bob, you have 2 items', ); }); + it('returns the short-form message in production mode', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).__DEV__ = false; + const { getHumanReadableMessage } = makeBundle(); + expect(getHumanReadableMessage(CODE_WITH_CONTEXT, { count: 2, name: 'bob' })).toBe( + `TestError #${CODE_WITH_CONTEXT}`, + ); + }); + it('does not read the messages map in production mode', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).__DEV__ = false; + const messagesProxy = new Proxy( + {}, + { + get() { + throw new Error('messages map was accessed in prod'); + }, + }, + ) as Readonly>; + const { getHumanReadableMessage, ErrorClass } = createCodedErrorClass({ + messages: messagesProxy, + name: 'TestError', + }); + expect(() => new ErrorClass(CODE_WITHOUT_CONTEXT)).not.toThrow(); + expect(() => getHumanReadableMessage(CODE_WITHOUT_CONTEXT)).not.toThrow(); + }); + }); + + describe('context preservation', () => { + it('preserves non-enumerable context properties', () => { + const { ErrorClass } = makeBundle(); + const ctx = {} as TestContext[typeof CODE_WITH_CONTEXT]; + Object.defineProperty(ctx, 'name', { enumerable: false, value: 'alice' }); + Object.defineProperty(ctx, 'count', { enumerable: false, value: 7 }); + const err = new ErrorClass(CODE_WITH_CONTEXT, ctx); + expect((err.context as { name: string }).name).toBe('alice'); + expect((err.context as { count: number }).count).toBe(7); + }); + it('preserves accessor (getter) context properties', () => { + const { ErrorClass } = makeBundle(); + let reads = 0; + const ctx = { + count: 1, + get name() { + reads++; + return 'alice'; + }, + } as unknown as TestContext[typeof CODE_WITH_CONTEXT]; + const err = new ErrorClass(CODE_WITH_CONTEXT, ctx); + expect((err.context as { name: string }).name).toBe('alice'); + expect(reads).toBeGreaterThan(0); + }); + it('does not leak inherited (prototype) properties into context', () => { + const { ErrorClass } = makeBundle(); + const proto = { inherited: 'nope' }; + const ctx = Object.create(proto) as TestContext[typeof CODE_WITH_CONTEXT]; + Object.assign(ctx, { count: 1, name: 'alice' }); + const err = new ErrorClass(CODE_WITH_CONTEXT, ctx); + expect(Object.prototype.hasOwnProperty.call(err.context, 'inherited')).toBe(false); + }); + it('strips `cause: undefined` from context but forwards it to ErrorOptions', () => { + const { ErrorClass } = makeBundle(); + const err = new ErrorClass(CODE_WITH_CONTEXT, { + cause: undefined, + count: 1, + name: 'x', + } as unknown as TestContext[typeof CODE_WITH_CONTEXT]); + expect(err.context).not.toHaveProperty('cause'); + }); + }); + + describe('isError guard additional hardening', () => { + it('rejects a same-name error whose context.__code is not a number', () => { + const { isError } = makeBundle(); + const foreign = new Error('bogus'); + foreign.name = 'TestError'; + (foreign as unknown as { context: object }).context = { __code: 'not-a-number' }; + expect(isError(foreign)).toBe(false); + }); }); describe('messagePostProcessor', () => { diff --git a/packages/errors/src/coded-error.ts b/packages/errors/src/coded-error.ts index 43ae45796..3189c0025 100644 --- a/packages/errors/src/coded-error.ts +++ b/packages/errors/src/coded-error.ts @@ -42,23 +42,27 @@ export interface CodedError< */ export interface CodedErrorDefinition { /** - * Optional hook called on the fully-interpolated dev-mode message immediately before it is - * passed to {@link Error}'s constructor. Use this to append a suffix based on context (for - * example, {@link SolanaError} uses it to append `" (instruction #N)"` to messages for error - * codes in the instruction-error range when the context carries an `index` key). + * Optional hook called on the fully-interpolated message immediately before it is used. Use + * this to append a suffix based on context (for example, {@link SolanaError} uses it to + * append `" (instruction #N)"` to messages for error codes in the instruction-error range + * when the context carries an `index` key). * - * Not invoked in production mode. + * Invoked only when a human-readable message is actually rendered — i.e. inside the + * {@link CodedErrorConstructor} when `__DEV__ === true`, and by + * {@link CodedErrorClassBundle.getHumanReadableMessage} when `__DEV__ === true`. In + * production-mode constructor paths the short-form `"{prefix} #{code}"` message is emitted + * without invoking the post-processor. */ messagePostProcessor?: (code: C, context: object, message: string) => string; /** * Human-readable message templates keyed by code. Use `$variable` tokens to interpolate * values from an error's context; escape a literal `$` with `\\$`. * - * Templates are only rendered when `__DEV__ === true`. In production builds the factory - * ignores this map and emits the short-form message described by - * {@link CodedErrorDefinition.prodDecodeCommand}. Unlike {@link SolanaError}'s own messages - * map, the factory does not arrange for these strings to be stripped from production - * bundles — consumers that want that must drop the templates in their own build pipeline. + * Read only when `__DEV__ === true`. In production builds the factory emits the short-form + * message described by {@link CodedErrorDefinition.prodDecodeCommand} and never reads this + * map. To allow your bundler to tree-shake the templates out of production builds, gate the + * reference at the call site — e.g. `messages: __DEV__ ? MyMessages : ({} as typeof MyMessages)` + * — so the module holding your templates is only reachable in dev. */ messages: Readonly>; /** @@ -71,10 +75,16 @@ export interface CodedErrorDefinition { * Optional shell command that recovers a human-readable message from an error code in * production builds. If supplied, production error messages take the form: * + * "{prefix} #{code}; Decode this error by running `{prodDecodeCommand} {code}`" + * + * and, when the error carries a non-empty context, an encoded-context segment is appended + * before the closing backtick: + * * "{prefix} #{code}; Decode this error by running `{prodDecodeCommand} {code} '{encodedContext}'`" * * where `{prefix}` is {@link CodedErrorDefinition.prodMessagePrefix} (falling back to - * {@link CodedErrorDefinition.name}). If omitted, production messages are simply `"{prefix} #{code}"`. + * {@link CodedErrorDefinition.name}). If this field is omitted, production messages are + * simply `"{prefix} #{code}"`. * * @example * ```ts @@ -203,8 +213,11 @@ export function createCodedErrorClass(...args: FormatterArgsFor): string { const [code, context] = args; const ctx = context ?? {}; - const rendered = formatMessageTemplate(messages[code], ctx); - return messagePostProcessor ? messagePostProcessor(code, ctx, rendered) : rendered; + if (__DEV__) { + const rendered = formatMessageTemplate(messages[code], ctx); + return messagePostProcessor ? messagePostProcessor(code, ctx, rendered) : rendered; + } + return `${prefix} #${code}`; } function getMessage(code: C, context: Record): string { @@ -265,16 +278,20 @@ export function createCodedErrorClass; return { diff --git a/packages/errors/src/message-formatter.ts b/packages/errors/src/message-formatter.ts index 70c63e0a8..8dcfec2f1 100644 --- a/packages/errors/src/message-formatter.ts +++ b/packages/errors/src/message-formatter.ts @@ -28,14 +28,15 @@ const TYPE = 't'; * @param messageFormatString The message template containing `$variable` tokens. * @param context An object whose keys correspond to the variables in the template. */ -export function formatMessageTemplate(messageFormatString: string, context: object = {}): string { - if (messageFormatString.length === 0) { +export function formatMessageTemplate(messageFormatString: string | undefined, context: object = {}): string { + if (!messageFormatString) { return ''; } + const template: string = messageFormatString; let state: State; function commitStateUpTo(endIndex?: number) { if (state[TYPE] === StateType.Variable) { - const variableName = messageFormatString.slice(state[START_INDEX] + 1, endIndex); + const variableName = template.slice(state[START_INDEX] + 1, endIndex); fragments.push( variableName in context @@ -44,18 +45,18 @@ export function formatMessageTemplate(messageFormatString: string, context: obje : `$${variableName}`, ); } else if (state[TYPE] === StateType.Text) { - fragments.push(messageFormatString.slice(state[START_INDEX], endIndex)); + fragments.push(template.slice(state[START_INDEX], endIndex)); } } const fragments: string[] = []; - messageFormatString.split('').forEach((char, ii) => { + template.split('').forEach((char, ii) => { if (ii === 0) { state = { [START_INDEX]: 0, [TYPE]: - messageFormatString[0] === '\\' + template[0] === '\\' ? StateType.EscapeSequence - : messageFormatString[0] === '$' + : template[0] === '$' ? StateType.Variable : StateType.Text, }; From 32643927292a40f86f94922bc70dbbf214367dc9 Mon Sep 17 00:00:00 2001 From: amilz <85324096+amilz@users.noreply.github.com> Date: Mon, 27 Apr 2026 08:46:05 -0700 Subject: [PATCH 3/7] chore: minor cleanup --- packages/errors/src/__tests__/coded-error-test.ts | 2 ++ packages/errors/src/__tests__/error-test.ts | 9 +++++++++ .../errors/src/__typetests__/coded-error-typetest.ts | 6 ++++++ packages/errors/src/coded-error.ts | 8 +++++++- packages/errors/src/error.ts | 1 + 5 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/errors/src/__tests__/coded-error-test.ts b/packages/errors/src/__tests__/coded-error-test.ts index 61adf7b22..40c9ffddc 100644 --- a/packages/errors/src/__tests__/coded-error-test.ts +++ b/packages/errors/src/__tests__/coded-error-test.ts @@ -40,7 +40,9 @@ describe('createCodedErrorClass', () => { const { ErrorClass } = makeBundle(); const err = new ErrorClass(CODE_WITHOUT_CONTEXT); expect(err).toBeInstanceOf(Error); + expect(ErrorClass.name).toBe('TestError'); expect(err.name).toBe('TestError'); + expect(err.constructor.name).toBe('TestError'); }); it('freezes the context object and sets __code', () => { const { ErrorClass } = makeBundle(); diff --git a/packages/errors/src/__tests__/error-test.ts b/packages/errors/src/__tests__/error-test.ts index 52fba1aa5..b27abbbc7 100644 --- a/packages/errors/src/__tests__/error-test.ts +++ b/packages/errors/src/__tests__/error-test.ts @@ -17,6 +17,15 @@ describe('SolanaError', () => { afterEach(() => { (globalThis as { __DEV__?: boolean }).__DEV__ = originalDev; }); + it('exposes the public constructor name on the class and instances', () => { + const err = new SolanaError( + // @ts-expect-error Mock error codes don't conform to `SolanaErrorCode` + 123, + undefined, + ); + expect(SolanaError.name).toBe('SolanaError'); + expect(err.constructor.name).toBe('SolanaError'); + }); describe('given an error with context', () => { let errorWithContext: SolanaError; beforeEach(() => { diff --git a/packages/errors/src/__typetests__/coded-error-typetest.ts b/packages/errors/src/__typetests__/coded-error-typetest.ts index fd829a3e3..4ac179b3b 100644 --- a/packages/errors/src/__typetests__/coded-error-typetest.ts +++ b/packages/errors/src/__typetests__/coded-error-typetest.ts @@ -42,6 +42,10 @@ new TestError(CODE_WITH_CONTEXT, { count: 1 }); err.context.__code satisfies typeof CODE_WITHOUT_CONTEXT; err.context.name satisfies string; err.context.count satisfies number; + // @ts-expect-error Context is frozen at runtime, so exposed properties are readonly. + err.context.name = 'b'; + // @ts-expect-error The error code tag is readonly. + err.context.__code = CODE_WITHOUT_CONTEXT; // @ts-expect-error No such property on this code's context. void err.context.nope; } @@ -60,6 +64,8 @@ new TestError(CODE_WITH_CONTEXT, { count: 1 }); if (isTestError(e, CODE_WITH_CONTEXT)) { e.context.name satisfies string; e.context.count satisfies number; + // @ts-expect-error Context is frozen at runtime, so narrowed properties are readonly. + e.context.name = 'b'; // @ts-expect-error Not a property of CODE_WITH_CONTEXT's context. void e.context.other; } diff --git a/packages/errors/src/coded-error.ts b/packages/errors/src/coded-error.ts index 3189c0025..a06a469ed 100644 --- a/packages/errors/src/coded-error.ts +++ b/packages/errors/src/coded-error.ts @@ -24,7 +24,8 @@ export type CodedErrorContextMap = { * @typeParam C The specific error code this instance carries (narrowed by the guard). */ type CodedErrorCodedContext> = { - [P in TCode]: Readonly<{ __code: P }> & (TContextMap[P] extends undefined ? object : NonNullable); + [P in TCode]: Readonly<{ __code: P }> & + (TContextMap[P] extends undefined ? object : Readonly>); }; export interface CodedError< @@ -122,6 +123,10 @@ type FormatterArgsFor< * that object is extracted and forwarded as {@link ErrorOptions.cause}. */ export interface CodedErrorConstructor> { + /** + * The configured constructor name. + */ + readonly name: string; new (...args: ConstructorArgsFor): CodedError; } @@ -272,6 +277,7 @@ export function createCodedErrorClass { if (!(e instanceof Error) || e.name !== name) { diff --git a/packages/errors/src/error.ts b/packages/errors/src/error.ts index e4fa40865..69642f6f1 100644 --- a/packages/errors/src/error.ts +++ b/packages/errors/src/error.ts @@ -30,6 +30,7 @@ const { ErrorClass, isError } = createCodedErrorClass( ...args: SolanaErrorContext[TErrorCode] extends undefined ? [code: TErrorCode, errorOptions?: ErrorOptions | undefined] From 84751784b46d1eec0f70f9dff1cd594477972f4c Mon Sep 17 00:00:00 2001 From: amilz <85324096+amilz@users.noreply.github.com> Date: Tue, 28 Apr 2026 07:27:56 -0700 Subject: [PATCH 4/7] chore: cleanup --- packages/errors/README.md | 6 ++--- packages/errors/src/coded-error.ts | 32 ++++++++++-------------- packages/errors/src/message-formatter.ts | 11 +++----- 3 files changed, 19 insertions(+), 30 deletions(-) diff --git a/packages/errors/README.md b/packages/errors/README.md index 21d8e2ddf..139072df1 100644 --- a/packages/errors/README.md +++ b/packages/errors/README.md @@ -88,7 +88,7 @@ try { ## Building your own coded error class -Downstream tools built on Kit (e.g. paymasters, wallets, programs) often want the same ergonomics as `SolanaError` — a strongly-typed class, a code-narrowing guard, dev/prod message branching — but without adding their own error codes to this package. Use `createCodedErrorClass` to mint a new error system owned by your package. +Downstream tools built on Kit (e.g. paymasters, wallets, programs) often want the same ergonomics as `SolanaError` (strongly-typed class, code-narrowing guard, dev/prod message branching) without adding their own error codes to this package. Use `createCodedErrorClass` to mint a coded error system owned by your package. ```ts import { createCodedErrorClass } from '@solana/errors'; @@ -120,9 +120,9 @@ try { } ``` -The factory takes over exactly the plumbing that you would otherwise duplicate — context freezing, `cause` extraction, `$variable` interpolation, a dev/prod message switch — while leaving your error codes, messages, and context shapes in your own package. Pass `prodDecodeCommand` (e.g. `'npx @your-pkg/errors decode --'`) if you ship a CLI for decoding error codes in production bundles. +The factory handles context freezing, `cause` extraction, `$variable` interpolation, and dev/prod message branching. Your codes, messages, and context shapes stay in your own package. Pass `prodDecodeCommand` (e.g. `'npx @your-pkg/errors decode --'`) if you ship a CLI for decoding error codes in production bundles. -The factory never reads the `messages` map when `__DEV__` is `false`. If you ship a bundler-targeted build where `__DEV__` is statically replaced with `false`, a downstream bundler can DCE the templates. If you want to guarantee the templates drop out of your own production bundles, gate the reference at the call site — e.g. `messages: __DEV__ ? MyMessages : ({} as typeof MyMessages)` — so the templates module is unreachable under a static `__DEV__ === false` replacement. +The factory only reads `messages` when `__DEV__` is `true`. To guarantee the templates drop out of your production bundles, gate the reference at the call site — e.g. `messages: __DEV__ ? MyMessages : ({} as typeof MyMessages)` — so the templates module is unreachable under a static `__DEV__ === false` replacement. If you want consumers to be able to write `MyError` as a type the same way they can with `SolanaError`, re-export a generic alias alongside the class: diff --git a/packages/errors/src/coded-error.ts b/packages/errors/src/coded-error.ts index a06a469ed..a017eee7c 100644 --- a/packages/errors/src/coded-error.ts +++ b/packages/errors/src/coded-error.ts @@ -43,33 +43,29 @@ export interface CodedError< */ export interface CodedErrorDefinition { /** - * Optional hook called on the fully-interpolated message immediately before it is used. Use - * this to append a suffix based on context (for example, {@link SolanaError} uses it to - * append `" (instruction #N)"` to messages for error codes in the instruction-error range - * when the context carries an `index` key). - * - * Invoked only when a human-readable message is actually rendered — i.e. inside the - * {@link CodedErrorConstructor} when `__DEV__ === true`, and by - * {@link CodedErrorClassBundle.getHumanReadableMessage} when `__DEV__ === true`. In - * production-mode constructor paths the short-form `"{prefix} #{code}"` message is emitted - * without invoking the post-processor. + * Optional hook called on the fully-interpolated message before it is used. Use this to + * append a context-derived suffix (for example, {@link SolanaError} appends + * `" (instruction #N)"` to messages in the instruction-error range when the context carries + * an `index` key). Only invoked when `__DEV__ === true`. */ messagePostProcessor?: (code: C, context: object, message: string) => string; /** * Human-readable message templates keyed by code. Use `$variable` tokens to interpolate * values from an error's context; escape a literal `$` with `\\$`. * - * Read only when `__DEV__ === true`. In production builds the factory emits the short-form - * message described by {@link CodedErrorDefinition.prodDecodeCommand} and never reads this - * map. To allow your bundler to tree-shake the templates out of production builds, gate the - * reference at the call site — e.g. `messages: __DEV__ ? MyMessages : ({} as typeof MyMessages)` - * — so the module holding your templates is only reachable in dev. + * Only read when `__DEV__ === true`. To let your bundler tree-shake the templates out of + * production builds, gate the reference at the call site — e.g. + * `messages: __DEV__ ? MyMessages : ({} as typeof MyMessages)`. */ messages: Readonly>; /** * The class name (and the value written to `error.name`). Downstream guards identify * instances by matching this exact string, so pick something unique (e.g. `'KoraError'`, * `'CodamaError'`). + * + * Note: `__code` is reserved on the context object — the factory writes it itself and + * frees you from having to declare it in your context shapes. Don't include `__code` in + * a context value passed to the constructor; it will be overwritten. */ name: string; /** @@ -255,7 +251,6 @@ export function createCodedErrorClass { - // If the `ErrorOptions` type ever changes, update this code. if (propName === 'cause') { errorOptions = { cause: descriptor.value }; } else { @@ -283,9 +278,8 @@ export function createCodedErrorClass Date: Thu, 30 Apr 2026 10:48:42 -0700 Subject: [PATCH 5/7] chore: address review --- packages/errors/README.md | 52 +++++++++++++++++++++++++++++- packages/errors/src/coded-error.ts | 25 +++++++++----- packages/errors/src/error.ts | 4 ++- 3 files changed, 71 insertions(+), 10 deletions(-) diff --git a/packages/errors/README.md b/packages/errors/README.md index 139072df1..dc81704e0 100644 --- a/packages/errors/README.md +++ b/packages/errors/README.md @@ -122,7 +122,13 @@ try { The factory handles context freezing, `cause` extraction, `$variable` interpolation, and dev/prod message branching. Your codes, messages, and context shapes stay in your own package. Pass `prodDecodeCommand` (e.g. `'npx @your-pkg/errors decode --'`) if you ship a CLI for decoding error codes in production bundles. -The factory only reads `messages` when `__DEV__` is `true`. To guarantee the templates drop out of your production bundles, gate the reference at the call site — e.g. `messages: __DEV__ ? MyMessages : ({} as typeof MyMessages)` — so the templates module is unreachable under a static `__DEV__ === false` replacement. +The factory only reads `messages` when `__DEV__` is `true`. To let your bundler tree-shake the templates out of production builds, supply `messages` as a function and gate the lookup with `__DEV__`: + +```ts +messages: code => (__DEV__ ? MyMessages[code] : ''), +``` + +Under a static `__DEV__ === false` replacement, the `MyMessages` reference lives only inside a dead branch, so the bundler can drop the module that holds your templates. If you want consumers to be able to write `MyError` as a type the same way they can with `SolanaError`, re-export a generic alias alongside the class: @@ -133,3 +139,47 @@ export type KoraError = CodedError = Omit< + CodedError, + 'cause' +> & { + readonly cause?: C extends KoraErrorCodeWithCause ? KoraError : unknown; +}; + +export interface KoraErrorWithDeprecatedCause extends Omit< + KoraError, + 'cause' +> { + /** @deprecated `cause` is no longer populated for this error code; read `context` instead. */ + readonly cause?: unknown; +} + +const { isError } = createCodedErrorClass({ + /* ... */ +}); + +export function isKoraError( + e: unknown, + code: C, +): e is KoraErrorWithDeprecatedCause; +export function isKoraError(e: unknown, code?: C): e is KoraError; +export function isKoraError(e: unknown, code?: KoraErrorCode): boolean { + return code === undefined ? isError(e) : isError(e, code); +} +``` + +The factory's `isError` runs the actual identity check; the overloads only refine the return type. Add or remove categories independently as your error system evolves. diff --git a/packages/errors/src/coded-error.ts b/packages/errors/src/coded-error.ts index a017eee7c..3e6201fa8 100644 --- a/packages/errors/src/coded-error.ts +++ b/packages/errors/src/coded-error.ts @@ -50,14 +50,22 @@ export interface CodedErrorDefinition { */ messagePostProcessor?: (code: C, context: object, message: string) => string; /** - * Human-readable message templates keyed by code. Use `$variable` tokens to interpolate - * values from an error's context; escape a literal `$` with `\\$`. + * Human-readable message templates. Use `$variable` tokens to interpolate values from an + * error's context; escape a literal `$` with `\\$`. May be supplied as either: * - * Only read when `__DEV__ === true`. To let your bundler tree-shake the templates out of - * production builds, gate the reference at the call site — e.g. - * `messages: __DEV__ ? MyMessages : ({} as typeof MyMessages)`. + * - A `Readonly>` keyed by code (gives exhaustiveness checking + * against the {@link TCode} union). + * - A `(code: TCode) => string` function. Prefer this form to let bundlers tree-shake your + * templates out of production builds — write the body as + * `(code) => (__DEV__ ? MyMessages[code] : '')` so the reference to `MyMessages` lives + * inside a `__DEV__`-gated branch and can be eliminated under a static + * `__DEV__ === false` replacement. + * + * Either form is only consulted when `__DEV__ === true`. In production the factory emits + * the short-form message described by {@link CodedErrorDefinition.prodDecodeCommand} and + * never invokes this lookup. */ - messages: Readonly>; + messages: Readonly> | ((code: TCode) => string); /** * The class name (and the value written to `error.name`). Downstream guards identify * instances by matching this exact string, so pick something unique (e.g. `'KoraError'`, @@ -210,12 +218,13 @@ export function createCodedErrorClass { const { messagePostProcessor, messages, name, prodDecodeCommand, prodMessagePrefix } = definition; const prefix = prodMessagePrefix ?? name; + const lookupTemplate = typeof messages === 'function' ? messages : (code: TCode) => messages[code]; function getHumanReadableMessage(...args: FormatterArgsFor): string { const [code, context] = args; const ctx = context ?? {}; if (__DEV__) { - const rendered = formatMessageTemplate(messages[code], ctx); + const rendered = formatMessageTemplate(lookupTemplate(code), ctx); return messagePostProcessor ? messagePostProcessor(code, ctx, rendered) : rendered; } return `${prefix} #${code}`; @@ -223,7 +232,7 @@ export function createCodedErrorClass(code: C, context: Record): string { if (__DEV__) { - const rendered = formatMessageTemplate(messages[code], context); + const rendered = formatMessageTemplate(lookupTemplate(code), context); return messagePostProcessor ? messagePostProcessor(code, context, rendered) : rendered; } let message = `${prefix} #${code}`; diff --git a/packages/errors/src/error.ts b/packages/errors/src/error.ts index 69642f6f1..958f86e2b 100644 --- a/packages/errors/src/error.ts +++ b/packages/errors/src/error.ts @@ -23,7 +23,9 @@ const { ErrorClass, isError } = createCodedErrorClass (__DEV__ ? SolanaErrorMessages[code] : ''), name: 'SolanaError', prodDecodeCommand: 'npx @solana/errors decode --', prodMessagePrefix: 'Solana error', From 9f85ebe09a67d0d5fe4eea4b1b17925174760734 Mon Sep 17 00:00:00 2001 From: amilz <85324096+amilz@users.noreply.github.com> Date: Mon, 4 May 2026 09:45:20 -0700 Subject: [PATCH 6/7] refactor: setup errors-core chore: cleanup --- .changeset/fine-snakes-grow.md | 8 ++ packages/errors-core/.gitignore | 2 + packages/errors-core/.npmrc | 1 + packages/errors-core/.prettierignore | 4 + packages/errors-core/CHANGELOG.md | 1 + packages/errors-core/LICENSE | 20 +++ packages/errors-core/README.md | 132 ++++++++++++++++++ packages/errors-core/package.json | 87 ++++++++++++ packages/errors-core/src/.npmignore | 4 + .../src/__tests__/coded-error-test.ts | 74 +++++----- .../src/__tests__/context-test.ts | 0 .../src/__typetests__/coded-error-typetest.ts | 0 .../src/coded-error.ts | 2 +- packages/errors-core/src/context.ts | 45 ++++++ packages/errors-core/src/index.ts | 37 +++++ packages/errors-core/src/message-formatter.ts | 85 +++++++++++ .../errors-core/tsconfig.declarations.json | 10 ++ packages/errors-core/tsconfig.json | 10 ++ packages/errors-core/typedoc.json | 7 + packages/errors/package.json | 1 + packages/errors/src/__tests__/error-test.ts | 50 ++++--- packages/errors/src/context.ts | 33 +---- packages/errors/src/error.ts | 13 +- packages/errors/src/index.ts | 10 +- packages/errors/src/message-formatter.ts | 89 +----------- pnpm-lock.yaml | 9 ++ 26 files changed, 555 insertions(+), 179 deletions(-) create mode 100644 .changeset/fine-snakes-grow.md create mode 100644 packages/errors-core/.gitignore create mode 100644 packages/errors-core/.npmrc create mode 100644 packages/errors-core/.prettierignore create mode 100644 packages/errors-core/CHANGELOG.md create mode 100644 packages/errors-core/LICENSE create mode 100644 packages/errors-core/README.md create mode 100644 packages/errors-core/package.json create mode 100644 packages/errors-core/src/.npmignore rename packages/{errors => errors-core}/src/__tests__/coded-error-test.ts (89%) rename packages/{errors => errors-core}/src/__tests__/context-test.ts (100%) rename packages/{errors => errors-core}/src/__typetests__/coded-error-typetest.ts (100%) rename packages/{errors => errors-core}/src/coded-error.ts (99%) create mode 100644 packages/errors-core/src/context.ts create mode 100644 packages/errors-core/src/index.ts create mode 100644 packages/errors-core/src/message-formatter.ts create mode 100644 packages/errors-core/tsconfig.declarations.json create mode 100644 packages/errors-core/tsconfig.json create mode 100644 packages/errors-core/typedoc.json diff --git a/.changeset/fine-snakes-grow.md b/.changeset/fine-snakes-grow.md new file mode 100644 index 000000000..33c8a4c31 --- /dev/null +++ b/.changeset/fine-snakes-grow.md @@ -0,0 +1,8 @@ +--- +'@solana/errors-core': minor +'@solana/errors': patch +--- + +Add `@solana/errors-core`, a new package containing the primitives — `createCodedErrorClass`, `formatMessageTemplate`, `encodeContextObject` / `decodeEncodedContext` — for building strongly-typed, numerically-coded JavaScript error classes. `@solana/errors` now consumes this package internally and re-exports `createCodedErrorClass` (and its associated types) so existing imports continue to work unchanged. + +Downstream tooling (paymasters, wallets, codegen tools, etc.) that wants its own coded error system should depend on `@solana/errors-core` directly rather than pulling in the full Kit error catalog. diff --git a/packages/errors-core/.gitignore b/packages/errors-core/.gitignore new file mode 100644 index 000000000..aff17b6df --- /dev/null +++ b/packages/errors-core/.gitignore @@ -0,0 +1,2 @@ +.docs/ +dist/ diff --git a/packages/errors-core/.npmrc b/packages/errors-core/.npmrc new file mode 100644 index 000000000..b6f27f135 --- /dev/null +++ b/packages/errors-core/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/packages/errors-core/.prettierignore b/packages/errors-core/.prettierignore new file mode 100644 index 000000000..2bd5f0063 --- /dev/null +++ b/packages/errors-core/.prettierignore @@ -0,0 +1,4 @@ +# Changelogs are autogenerated, so leave them alone +CHANGELOG.md + +dist/ diff --git a/packages/errors-core/CHANGELOG.md b/packages/errors-core/CHANGELOG.md new file mode 100644 index 000000000..bb5aa1318 --- /dev/null +++ b/packages/errors-core/CHANGELOG.md @@ -0,0 +1 @@ +# @solana/errors-core diff --git a/packages/errors-core/LICENSE b/packages/errors-core/LICENSE new file mode 100644 index 000000000..ec09953d3 --- /dev/null +++ b/packages/errors-core/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2023 Solana Labs, Inc + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/errors-core/README.md b/packages/errors-core/README.md new file mode 100644 index 000000000..7263a21e6 --- /dev/null +++ b/packages/errors-core/README.md @@ -0,0 +1,132 @@ +[![npm][npm-image]][npm-url] +[![npm-downloads][npm-downloads-image]][npm-url] +
+[![code-style-prettier][code-style-prettier-image]][code-style-prettier-url] + +[code-style-prettier-image]: https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square +[code-style-prettier-url]: https://github.com/prettier/prettier +[npm-downloads-image]: https://img.shields.io/npm/dm/@solana/errors-core?style=flat +[npm-image]: https://img.shields.io/npm/v/@solana/errors-core?style=flat +[npm-url]: https://www.npmjs.com/package/@solana/errors-core + +# @solana/errors-core + +Primitives for building strongly-typed, numerically-coded JavaScript error classes — the factory machinery that powers [`SolanaError`](https://www.solanakit.com/api/classes/_solana_errors.SolanaError.html), exposed as a standalone package so that downstream tools (paymasters, wallets, program clients, etc.) can build their own coded error systems without growing the [`@solana/errors`](../errors/) catalog. + +If you are throwing or catching errors from Kit itself, you want [`@solana/errors`](../errors/), not this package. + +## Installation + +```package-install +@solana/errors-core +``` + +## Usage + +Define your codes, the context shape associated with each code, and the human-readable templates. `createCodedErrorClass` returns a constructor, a code-narrowing type guard, and a standalone message formatter. + +```ts +import { createCodedErrorClass } from '@solana/errors-core'; + +export const KORA_ERROR__ACCOUNT_NOT_FOUND = -32050 as const; +export const KORA_ERROR__RATE_LIMIT_EXCEEDED = -32030 as const; + +export type KoraErrorCode = typeof KORA_ERROR__ACCOUNT_NOT_FOUND | typeof KORA_ERROR__RATE_LIMIT_EXCEEDED; + +export type KoraErrorContext = { + [KORA_ERROR__ACCOUNT_NOT_FOUND]: { address: string }; + [KORA_ERROR__RATE_LIMIT_EXCEEDED]: undefined; +}; + +export const { ErrorClass: KoraError, isError: isKoraError } = createCodedErrorClass({ + messages: { + [KORA_ERROR__ACCOUNT_NOT_FOUND]: 'Account $address not found', + [KORA_ERROR__RATE_LIMIT_EXCEEDED]: 'Rate limit exceeded', + }, + name: 'KoraError', + prodDecodeCommand: 'npx @kora/errors decode --', +}); +``` + +Throw and narrow: + +```ts +try { + /* ... */ +} catch (e) { + if (isKoraError(e, KORA_ERROR__ACCOUNT_NOT_FOUND)) { + // `e.context.address` is now typed as `string`. + displayError(`Missing account ${e.context.address}`); + } +} +``` + +### Message templates + +Templates use `$variable` tokens to interpolate values from an error's context. Tokens with no matching context key are rendered literally; escape a literal `$` with `\\$`. + +You can supply `messages` as either: + +- A `Readonly>` keyed by code (gives exhaustiveness checking against your code union), or +- A `(code: TCode) => string` function. Prefer this form to let bundlers tree-shake your templates out of production builds — write the body as `(code) => (__DEV__ ? MyMessages[code] : '')` so the reference to `MyMessages` lives inside a `__DEV__`-gated branch and can be eliminated under a static `__DEV__ === false` replacement. + +The factory only consults `messages` when `__DEV__ === true`. In production it emits the short-form decode hint described below. + +### Production mode + +When your bundler sets `__DEV__` to `false`, error messages are stripped from the bundle. The factory emits a short-form message of the form: + +``` +KoraError #-32050; Decode this error by running `npx @kora/errors decode -- -32050 'BASE64_CONTEXT'` +``` + +…where `BASE64_CONTEXT` is produced by [`encodeContextObject`](./src/context.ts). The pair `encodeContextObject` / `decodeEncodedContext` round-trip the context through `URLSearchParams`, so it is lossy for non-string types: the decoder receives string values back. + +If you don't configure a `prodDecodeCommand`, the production message is just `KoraError #-32050`. Either way, **you should pair this package with a small `decode` CLI in your error package** so consumers can recover the human-readable message from a code/context pair shipped by a production build. + +A minimal decoder is just a few lines: + +```ts +#!/usr/bin/env node +// bin/decode.ts +import { decodeEncodedContext } from '@solana/errors-core'; +import { getKoraErrorMessage } from '../src'; + +const [, , rawCode, encodedContext] = process.argv; +const code = Number.parseInt(rawCode, 10); +const context = encodedContext ? decodeEncodedContext(encodedContext) : undefined; +console.log(getKoraErrorMessage(code, context)); +``` + +…where `getKoraErrorMessage` is the `getHumanReadableMessage` returned by your `createCodedErrorClass` call. Wire it up in your package's `package.json`: + +```json +{ + "bin": { + "@kora/errors": "./bin/decode.js" + } +} +``` + +…and the `prodDecodeCommand: 'npx @kora/errors decode --'` you configured will invoke it. See [`@solana/errors`'s `cli.ts`](../errors/src/cli.ts) for a fuller reference (argument validation, colored output, `commander`-based parsing). + +## Rules for publishing coded errors + +Coded errors are part of your public, wire-format-stable surface area. Once you've published a code, treat it like a database migration: + +- **Don't remove an error.** Old clients still throw it. +- **Don't change the meaning of an error message.** It will become misleading. +- **Don't change or reorder error codes.** Decoders look codes up by number. +- **Don't change or remove members of an error's context.** Old throws will still ship the old shape. + +When an older client throws an error, you want to make sure it can always be decoded. If you make any of the changes above, old clients won't have received your changes — and the errors they throw will become impossible to decode going forward. + +The accepted way to evolve the system is to **add** new codes / context fields, never to mutate existing ones. + +## API + +- `createCodedErrorClass(definition)` — returns `{ ErrorClass, isError, getHumanReadableMessage }`. +- `formatMessageTemplate(template, context)` — render a single `$variable` template; the lower-level primitive used by the factory. +- `encodeContextObject(context)` / `decodeEncodedContext(encoded)` — the round-trip used to embed context in production messages. Use `decodeEncodedContext` in your `decode` CLI. + +See the [generated API reference](https://www.solanakit.com/api) for the full type signatures. diff --git a/packages/errors-core/package.json b/packages/errors-core/package.json new file mode 100644 index 000000000..910b841e0 --- /dev/null +++ b/packages/errors-core/package.json @@ -0,0 +1,87 @@ +{ + "name": "@solana/errors-core", + "version": "6.8.0", + "description": "Primitives for building strongly-typed, numerically-coded JavaScript error classes", + "homepage": "https://www.solanakit.com/api#solanaerrorscore", + "exports": { + "edge-light": { + "import": "./dist/index.node.mjs", + "require": "./dist/index.node.cjs" + }, + "workerd": { + "import": "./dist/index.node.mjs", + "require": "./dist/index.node.cjs" + }, + "browser": { + "import": "./dist/index.browser.mjs", + "require": "./dist/index.browser.cjs" + }, + "node": { + "import": "./dist/index.node.mjs", + "require": "./dist/index.node.cjs" + }, + "react-native": "./dist/index.native.mjs", + "types": "./dist/types/index.d.ts" + }, + "browser": { + "./dist/index.node.cjs": "./dist/index.browser.cjs", + "./dist/index.node.mjs": "./dist/index.browser.mjs" + }, + "main": "./dist/index.node.cjs", + "module": "./dist/index.node.mjs", + "react-native": "./dist/index.native.mjs", + "types": "./dist/types/index.d.ts", + "type": "commonjs", + "files": [ + "./dist/", + "./src/" + ], + "sideEffects": false, + "keywords": [ + "blockchain", + "solana", + "web3" + ], + "scripts": { + "compile:docs": "typedoc", + "compile:js": "tsup --config build-scripts/tsup.config.package.ts", + "compile:typedefs": "tsc -p ./tsconfig.declarations.json", + "dev": "NODE_OPTIONS=\"--no-experimental-webstorage\" jest -c ../../node_modules/@solana/test-config/jest-dev.config.js --rootDir . --watch", + "prepublishOnly": "pnpm pkg delete devDependencies", + "publish-impl": "npm view $npm_package_name@$npm_package_version > /dev/null 2>&1 || (pnpm publish --tag ${PUBLISH_TAG:-canary} --access public --no-git-checks && (([ -n \"${GITHUB_OUTPUT:-}\" ] && echo 'published=true' >> \"$GITHUB_OUTPUT\") || true) && (([ \"$PUBLISH_TAG\" != \"canary\" ] && ../build-scripts/maybe-tag-latest.ts --token \"$GITHUB_TOKEN\" $npm_package_name@$npm_package_version) || true))", + "publish-packages": "pnpm prepublishOnly && pnpm publish-impl", + "style:fix": "pnpm eslint --fix src && pnpm prettier --log-level warn --ignore-unknown --write ./*", + "test:lint": "TERM_OVERRIDE=\"${TURBO_HASH:+dumb}\" TERM=${TERM_OVERRIDE:-$TERM} jest -c ../../node_modules/@solana/test-config/jest-lint.config.js --rootDir . --silent", + "test:prettier": "TERM_OVERRIDE=\"${TURBO_HASH:+dumb}\" TERM=${TERM_OVERRIDE:-$TERM} jest -c ../../node_modules/@solana/test-config/jest-prettier.config.js --rootDir . --silent", + "test:treeshakability:browser": "agadoo dist/index.browser.mjs", + "test:treeshakability:native": "agadoo dist/index.native.mjs", + "test:treeshakability:node": "agadoo dist/index.node.mjs", + "test:typecheck": "tsc --noEmit", + "test:unit:browser": "NODE_OPTIONS=\"--no-experimental-webstorage\" TERM_OVERRIDE=\"${TURBO_HASH:+dumb}\" TERM=${TERM_OVERRIDE:-$TERM} jest -c ../../node_modules/@solana/test-config/jest-unit.config.browser.js --rootDir . --silent", + "test:unit:node": "NODE_OPTIONS=\"--no-experimental-webstorage\" TERM_OVERRIDE=\"${TURBO_HASH:+dumb}\" TERM=${TERM_OVERRIDE:-$TERM} jest -c ../../node_modules/@solana/test-config/jest-unit.config.node.js --rootDir . --silent" + }, + "author": "Solana Labs Maintainers ", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/anza-xyz/kit" + }, + "bugs": { + "url": "https://github.com/anza-xyz/kit/issues" + }, + "browserslist": [ + "supports bigint and not dead", + "maintained node versions" + ], + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + }, + "engines": { + "node": ">=20.18.0" + } +} diff --git a/packages/errors-core/src/.npmignore b/packages/errors-core/src/.npmignore new file mode 100644 index 000000000..b75823c19 --- /dev/null +++ b/packages/errors-core/src/.npmignore @@ -0,0 +1,4 @@ +__benchmarks__/ +__mocks__/ +__tests__/ +__typetests__/ diff --git a/packages/errors/src/__tests__/coded-error-test.ts b/packages/errors-core/src/__tests__/coded-error-test.ts similarity index 89% rename from packages/errors/src/__tests__/coded-error-test.ts rename to packages/errors-core/src/__tests__/coded-error-test.ts index 40c9ffddc..5cb74baaf 100644 --- a/packages/errors/src/__tests__/coded-error-test.ts +++ b/packages/errors-core/src/__tests__/coded-error-test.ts @@ -28,8 +28,7 @@ describe('createCodedErrorClass', () => { let originalDev: boolean | undefined; beforeEach(() => { originalDev = (globalThis as { __DEV__?: boolean }).__DEV__; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (globalThis as any).__DEV__ = true; + (globalThis as { __DEV__?: boolean }).__DEV__ = true; }); afterEach(() => { (globalThis as { __DEV__?: boolean }).__DEV__ = originalDev; @@ -44,15 +43,23 @@ describe('createCodedErrorClass', () => { expect(err.name).toBe('TestError'); expect(err.constructor.name).toBe('TestError'); }); - it('freezes the context object and sets __code', () => { + it('sets __code on the context', () => { const { ErrorClass } = makeBundle(); const err = new ErrorClass(CODE_WITH_CONTEXT, { count: 3, name: 'world' }); expect(err.context).toHaveProperty('__code', CODE_WITH_CONTEXT); + }); + it('preserves all context properties', () => { + const { ErrorClass } = makeBundle(); + const err = new ErrorClass(CODE_WITH_CONTEXT, { count: 3, name: 'world' }); expect(err.context).toHaveProperty('name', 'world'); expect(err.context).toHaveProperty('count', 3); + }); + it('freezes the context object', () => { + const { ErrorClass } = makeBundle(); + const err = new ErrorClass(CODE_WITH_CONTEXT, { count: 3, name: 'world' }); expect(err.context).toBeFrozenObject(); }); - it('exposes a default empty-ish context for codes without context', () => { + it('exposes a context with only __code for codes without context', () => { const { ErrorClass } = makeBundle(); const err = new ErrorClass(CODE_WITHOUT_CONTEXT); expect(err.context).toEqual({ __code: CODE_WITHOUT_CONTEXT }); @@ -125,6 +132,13 @@ describe('createCodedErrorClass', () => { foreign.name = 'TestError'; expect(() => isError(foreign, CODE_WITH_CONTEXT)).not.toThrow(); }); + it('rejects a same-name error whose context.__code is not a number', () => { + const { isError } = makeBundle(); + const foreign = new Error('bogus'); + foreign.name = 'TestError'; + (foreign as unknown as { context: object }).context = { __code: 'not-a-number' }; + expect(isError(foreign)).toBe(false); + }); it('does not cross-match between two bundles with different names', () => { const a = makeBundle(); const b = createCodedErrorClass({ @@ -149,30 +163,22 @@ describe('createCodedErrorClass', () => { ); }); it('returns the short-form message in production mode', () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (globalThis as any).__DEV__ = false; + (globalThis as { __DEV__?: boolean }).__DEV__ = false; const { getHumanReadableMessage } = makeBundle(); expect(getHumanReadableMessage(CODE_WITH_CONTEXT, { count: 2, name: 'bob' })).toBe( `TestError #${CODE_WITH_CONTEXT}`, ); }); it('does not read the messages map in production mode', () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (globalThis as any).__DEV__ = false; - const messagesProxy = new Proxy( - {}, - { - get() { - throw new Error('messages map was accessed in prod'); - }, - }, - ) as Readonly>; + (globalThis as { __DEV__?: boolean }).__DEV__ = false; + const messagesFn = jest.fn(); const { getHumanReadableMessage, ErrorClass } = createCodedErrorClass({ - messages: messagesProxy, + messages: messagesFn as unknown as (code: TestCode) => string, name: 'TestError', }); - expect(() => new ErrorClass(CODE_WITHOUT_CONTEXT)).not.toThrow(); - expect(() => getHumanReadableMessage(CODE_WITHOUT_CONTEXT)).not.toThrow(); + new ErrorClass(CODE_WITHOUT_CONTEXT); + getHumanReadableMessage(CODE_WITHOUT_CONTEXT); + expect(messagesFn).not.toHaveBeenCalled(); }); }); @@ -188,17 +194,16 @@ describe('createCodedErrorClass', () => { }); it('preserves accessor (getter) context properties', () => { const { ErrorClass } = makeBundle(); - let reads = 0; + const nameGetter = jest.fn().mockReturnValue('alice'); const ctx = { count: 1, get name() { - reads++; - return 'alice'; + return nameGetter() as string; }, } as unknown as TestContext[typeof CODE_WITH_CONTEXT]; const err = new ErrorClass(CODE_WITH_CONTEXT, ctx); expect((err.context as { name: string }).name).toBe('alice'); - expect(reads).toBeGreaterThan(0); + expect(nameGetter).toHaveBeenCalled(); }); it('does not leak inherited (prototype) properties into context', () => { const { ErrorClass } = makeBundle(); @@ -216,16 +221,8 @@ describe('createCodedErrorClass', () => { name: 'x', } as unknown as TestContext[typeof CODE_WITH_CONTEXT]); expect(err.context).not.toHaveProperty('cause'); - }); - }); - - describe('isError guard additional hardening', () => { - it('rejects a same-name error whose context.__code is not a number', () => { - const { isError } = makeBundle(); - const foreign = new Error('bogus'); - foreign.name = 'TestError'; - (foreign as unknown as { context: object }).context = { __code: 'not-a-number' }; - expect(isError(foreign)).toBe(false); + expect('cause' in err).toBe(true); + expect(err.cause).toBeUndefined(); }); }); @@ -262,8 +259,7 @@ describe('createCodedErrorClass', () => { expect(getHumanReadableMessage(CODE_WITH_CONTEXT, { count: 1, name: 'bob' })).toBe('Hello bob (suffix)'); }); it('is not invoked in production mode', () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (globalThis as any).__DEV__ = false; + (globalThis as { __DEV__?: boolean }).__DEV__ = false; const postProcessor = jest.fn((_code, _ctx, message) => `${message}!!`); const { ErrorClass } = createCodedErrorClass({ messagePostProcessor: postProcessor, @@ -282,8 +278,7 @@ describe('createCodedErrorClass', () => { describe('production mode messaging', () => { beforeEach(() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (globalThis as any).__DEV__ = false; + (globalThis as { __DEV__?: boolean }).__DEV__ = false; }); // Top-level afterEach restores __DEV__ to its pre-test value. @@ -324,6 +319,11 @@ describe('createCodedErrorClass', () => { `Test error #${CODE_WITHOUT_CONTEXT}; Decode this error by running \`npx @test/errors decode -- ${CODE_WITHOUT_CONTEXT}\``, ); }); + it('does not append an encoded context when no prodDecodeCommand is configured', () => { + const { ErrorClass } = makeBundle(); + const err = new ErrorClass(CODE_WITH_CONTEXT, { count: 1, name: 'x' }); + expect(err.message).toBe(`TestError #${CODE_WITH_CONTEXT}`); + }); it('appends an encoded context when present', () => { const { ErrorClass } = createCodedErrorClass({ messages: { diff --git a/packages/errors/src/__tests__/context-test.ts b/packages/errors-core/src/__tests__/context-test.ts similarity index 100% rename from packages/errors/src/__tests__/context-test.ts rename to packages/errors-core/src/__tests__/context-test.ts diff --git a/packages/errors/src/__typetests__/coded-error-typetest.ts b/packages/errors-core/src/__typetests__/coded-error-typetest.ts similarity index 100% rename from packages/errors/src/__typetests__/coded-error-typetest.ts rename to packages/errors-core/src/__typetests__/coded-error-typetest.ts diff --git a/packages/errors/src/coded-error.ts b/packages/errors-core/src/coded-error.ts similarity index 99% rename from packages/errors/src/coded-error.ts rename to packages/errors-core/src/coded-error.ts index 3e6201fa8..f0532158c 100644 --- a/packages/errors/src/coded-error.ts +++ b/packages/errors-core/src/coded-error.ts @@ -179,7 +179,7 @@ export interface CodedErrorClassBundle({ + * messages: { + * [KORA_ERROR__ACCOUNT_NOT_FOUND]: 'Account $address not found', + * [KORA_ERROR__RATE_LIMIT_EXCEEDED]: 'Rate limit exceeded', + * }, + * name: 'KoraError', + * }); + * ``` + * + * @packageDocumentation + */ +export * from './coded-error'; +export * from './context'; +export * from './message-formatter'; diff --git a/packages/errors-core/src/message-formatter.ts b/packages/errors-core/src/message-formatter.ts new file mode 100644 index 000000000..280523212 --- /dev/null +++ b/packages/errors-core/src/message-formatter.ts @@ -0,0 +1,85 @@ +const enum StateType { + EscapeSequence, + Text, + Variable, +} +type State = Readonly<{ + [START_INDEX]: number; + [TYPE]: StateType; +}>; +const START_INDEX = 'i'; +const TYPE = 't'; + +/** + * Interpolates `$variable` tokens in a message template with values from a context object. + * Tokens with no matching context key are rendered literally; escape a literal `$` with `\\$`. + * + * Shared by {@link createCodedErrorClass} and any downstream tooling that wants to render the + * same template syntax outside of a thrown error. + */ +export function formatMessageTemplate(messageFormatString: string | undefined, context: object = {}): string { + if (!messageFormatString) { + return ''; + } + const template: string = messageFormatString; + let state: State; + function commitStateUpTo(endIndex?: number) { + if (state[TYPE] === StateType.Variable) { + const variableName = template.slice(state[START_INDEX] + 1, endIndex); + + fragments.push( + variableName in context + ? // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `${context[variableName as keyof typeof context]}` + : `$${variableName}`, + ); + } else if (state[TYPE] === StateType.Text) { + fragments.push(template.slice(state[START_INDEX], endIndex)); + } + } + const fragments: string[] = []; + template.split('').forEach((char, ii) => { + if (ii === 0) { + state = { + [START_INDEX]: 0, + [TYPE]: + template[0] === '\\' + ? StateType.EscapeSequence + : template[0] === '$' + ? StateType.Variable + : StateType.Text, + }; + return; + } + let nextState; + switch (state[TYPE]) { + case StateType.EscapeSequence: + nextState = { [START_INDEX]: ii, [TYPE]: StateType.Text }; + break; + case StateType.Text: + if (char === '\\') { + nextState = { [START_INDEX]: ii, [TYPE]: StateType.EscapeSequence }; + } else if (char === '$') { + nextState = { [START_INDEX]: ii, [TYPE]: StateType.Variable }; + } + break; + case StateType.Variable: + if (char === '\\') { + nextState = { [START_INDEX]: ii, [TYPE]: StateType.EscapeSequence }; + } else if (char === '$') { + nextState = { [START_INDEX]: ii, [TYPE]: StateType.Variable }; + } else if (!char.match(/\w/)) { + nextState = { [START_INDEX]: ii, [TYPE]: StateType.Text }; + } + break; + } + if (nextState) { + if (state !== nextState) { + commitStateUpTo(ii); + } + state = nextState; + } + }); + commitStateUpTo(); + return fragments.join(''); +} diff --git a/packages/errors-core/tsconfig.declarations.json b/packages/errors-core/tsconfig.declarations.json new file mode 100644 index 000000000..67ad58e02 --- /dev/null +++ b/packages/errors-core/tsconfig.declarations.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "outDir": "./dist/types" + }, + "extends": "./tsconfig.json", + "include": ["../build-scripts/build-time-constants.d.ts", "src/index.ts"] +} diff --git a/packages/errors-core/tsconfig.json b/packages/errors-core/tsconfig.json new file mode 100644 index 000000000..accaa4e99 --- /dev/null +++ b/packages/errors-core/tsconfig.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "lib": ["DOM", "ES2015", "ES2022.Error"], + "resolveJsonModule": true + }, + "display": "@solana/errors-core", + "extends": "../tsconfig/base.json", + "include": ["../build-scripts/build-time-constants.d.ts", "src"] +} diff --git a/packages/errors-core/typedoc.json b/packages/errors-core/typedoc.json new file mode 100644 index 000000000..e08261d87 --- /dev/null +++ b/packages/errors-core/typedoc.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "extends": ["../../typedoc.json"], + "entryPoints": ["src/index.ts"], + "out": "./.docs", + "readme": "none" +} diff --git a/packages/errors/package.json b/packages/errors/package.json index c497d3d46..55b1f1609 100644 --- a/packages/errors/package.json +++ b/packages/errors/package.json @@ -75,6 +75,7 @@ "maintained node versions" ], "dependencies": { + "@solana/errors-core": "workspace:*", "chalk": "5.6.2", "commander": "14.0.3" }, diff --git a/packages/errors/src/__tests__/error-test.ts b/packages/errors/src/__tests__/error-test.ts index b27abbbc7..b666a0e6c 100644 --- a/packages/errors/src/__tests__/error-test.ts +++ b/packages/errors/src/__tests__/error-test.ts @@ -2,17 +2,27 @@ import '@solana/test-matchers/toBeFrozenObject'; import { SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN } from '../codes'; import { isSolanaError, SolanaError } from '../error'; -import { formatMessageTemplate } from '../message-formatter'; +import * as MessagesModule from '../messages'; -jest.mock('../message-formatter'); +jest.mock('../messages', () => ({ + get SolanaErrorMessages() { + return {}; + }, + __esModule: true, +})); + +function mockMessage(code: number, message: string) { + jest.spyOn(MessagesModule, 'SolanaErrorMessages', 'get').mockReturnValue({ + [code]: message, + } as unknown as typeof MessagesModule.SolanaErrorMessages); +} describe('SolanaError', () => { let originalDev: boolean | undefined; beforeEach(() => { originalDev = (globalThis as { __DEV__?: boolean }).__DEV__; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (globalThis as any).__DEV__ = true; - jest.mocked(formatMessageTemplate).mockReturnValue('mock message'); + (globalThis as { __DEV__?: boolean }).__DEV__ = true; + mockMessage(123, 'mock message'); }); afterEach(() => { (globalThis as { __DEV__?: boolean }).__DEV__ = originalDev; @@ -44,12 +54,6 @@ describe('SolanaError', () => { it('exposes no cause', () => { expect(errorWithContext.cause).toBeUndefined(); }); - it('formats the message template with the context', () => { - expect(formatMessageTemplate).toHaveBeenCalledWith( - undefined, // `messages[123]` is not a real Solana error message. - expect.objectContaining({ foo: 'bar' }), - ); - }); it('freezes the context object', () => { expect(errorWithContext.context).toBeFrozenObject(); }); @@ -97,8 +101,8 @@ describe('SolanaError', () => { expect(errorWithOption.context).not.toHaveProperty(propName); }); }); - it('sets its message to the output of the message formatter', () => { - jest.mocked(formatMessageTemplate).mockReturnValue('o no'); + it('sets its message to the formatted template for the code', () => { + mockMessage(456, 'o no'); const error456 = new SolanaError( // @ts-expect-error Mock error codes don't conform to `SolanaErrorCode` 456, @@ -108,7 +112,7 @@ describe('SolanaError', () => { }); describe('instruction-index suffix (dev mode)', () => { beforeEach(() => { - jest.mocked(formatMessageTemplate).mockReturnValue('Some instruction error'); + mockMessage(SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN, 'Some instruction error'); }); it('appends `(instruction #N)` for codes in the instruction-error range when context carries `index`', () => { const err = new SolanaError(SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN, { errorName: 'X', index: 0 }); @@ -125,6 +129,7 @@ describe('SolanaError', () => { expect(err.message).toBe('Some instruction error'); }); it('does not append the suffix to non-instruction error codes', () => { + mockMessage(123, 'Some instruction error'); const err = new SolanaError( // @ts-expect-error Mock error codes don't conform to `SolanaErrorCode` 123, @@ -134,9 +139,17 @@ describe('SolanaError', () => { }); }); describe('in production mode', () => { + let originalNodeEnv: string | undefined; beforeEach(() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (globalThis as any).__DEV__ = false; + (globalThis as { __DEV__?: boolean }).__DEV__ = false; + // The compiled `@solana/errors-core` (loaded from `dist`) replaces `__DEV__` with + // `process.env.NODE_ENV !== "production"`, so toggling `globalThis.__DEV__` alone is + // not enough to put it into prod mode. + originalNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + }); + afterEach(() => { + process.env.NODE_ENV = originalNodeEnv; }); // Outer `afterEach` restores `__DEV__` to its pre-test value. it('renders the prod message prefix and decode command without context', () => { @@ -165,7 +178,10 @@ describe('SolanaError', () => { describe('isSolanaError()', () => { let error123: SolanaError; beforeEach(() => { - jest.mocked(formatMessageTemplate).mockReturnValue('mock message'); + jest.spyOn(MessagesModule, 'SolanaErrorMessages', 'get').mockReturnValue({ + // @ts-expect-error Mock error codes don't conform to `SolanaErrorCode`. + 123: 'mock message', + }); // @ts-expect-error Mock error codes don't conform to `SolanaErrorCode` error123 = new SolanaError(123); }); diff --git a/packages/errors/src/context.ts b/packages/errors/src/context.ts index b81b4d875..2a79be624 100644 --- a/packages/errors/src/context.ts +++ b/packages/errors/src/context.ts @@ -882,35 +882,4 @@ export type SolanaErrorContext = ReadonlyContextValue< > >; -export function decodeEncodedContext(encodedContext: string): object { - const decodedUrlString = __NODEJS__ ? Buffer.from(encodedContext, 'base64').toString('utf8') : atob(encodedContext); - return Object.fromEntries(new URLSearchParams(decodedUrlString).entries()); -} - -function encodeValue(value: unknown): string { - if (Array.isArray(value)) { - const commaSeparatedValues = value.map(encodeValue).join('%2C%20' /* ", " */); - return '%5B' /* "[" */ + commaSeparatedValues + /* "]" */ '%5D'; - } else if (typeof value === 'bigint') { - return `${value}n`; - } else { - return encodeURIComponent( - String( - value != null && Object.getPrototypeOf(value) === null - ? // Plain objects with no prototype don't have a `toString` method. - // Convert them before stringifying them. - { ...(value as object) } - : value, - ), - ); - } -} - -function encodeObjectContextEntry([key, value]: [string, unknown]): `${typeof key}=${string}` { - return `${key}=${encodeValue(value)}`; -} - -export function encodeContextObject(context: object): string { - const searchParamsString = Object.entries(context).map(encodeObjectContextEntry).join('&'); - return __NODEJS__ ? Buffer.from(searchParamsString, 'utf8').toString('base64') : btoa(searchParamsString); -} +export { decodeEncodedContext, encodeContextObject } from '@solana/errors-core'; diff --git a/packages/errors/src/error.ts b/packages/errors/src/error.ts index 958f86e2b..d13d817cb 100644 --- a/packages/errors/src/error.ts +++ b/packages/errors/src/error.ts @@ -1,4 +1,5 @@ -import { type CodedError, createCodedErrorClass } from './coded-error'; +import { type CodedError, createCodedErrorClass } from '@solana/errors-core'; + import { SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN, SolanaErrorCode, @@ -12,7 +13,7 @@ const INSTRUCTION_ERROR_RANGE_SIZE = 1000; type SolanaErrorContextMap = { [P in SolanaErrorCode]: SolanaErrorContext[P] }; -const { ErrorClass, isError } = createCodedErrorClass({ +const SolanaErrorBundle = /*#__PURE__*/ createCodedErrorClass({ messagePostProcessor: (code, context, message) => { if ( code >= SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN && @@ -40,11 +41,15 @@ type SolanaErrorConstructor = { ): SolanaError; }; +function getSolanaErrorConstructor(): SolanaErrorConstructor { + return SolanaErrorBundle.ErrorClass as unknown as SolanaErrorConstructor; +} + /** * Encapsulates an error's stacktrace, a Solana-specific numeric code that indicates what went * wrong, and optional context if the type of error indicated by the code supports it. */ -export const SolanaError = ErrorClass as unknown as SolanaErrorConstructor; +export const SolanaError = /*#__PURE__*/ getSolanaErrorConstructor(); /** * The type of an instance of {@link SolanaError}. Narrows `context` to the shape associated with @@ -132,5 +137,5 @@ export function isSolanaError( code?: TErrorCode, ): e is SolanaError; export function isSolanaError(e: unknown, code?: SolanaErrorCode): boolean { - return code === undefined ? isError(e) : isError(e, code); + return code === undefined ? SolanaErrorBundle.isError(e) : SolanaErrorBundle.isError(e, code); } diff --git a/packages/errors/src/index.ts b/packages/errors/src/index.ts index 1529116f2..1d02c27e3 100644 --- a/packages/errors/src/index.ts +++ b/packages/errors/src/index.ts @@ -63,7 +63,15 @@ * * @packageDocumentation */ -export * from './coded-error'; +export { + type CodedError, + type CodedErrorClassBundle, + type CodedErrorConstructor, + type CodedErrorContextMap, + type CodedErrorDefinition, + type CodedErrorGuard, + createCodedErrorClass, +} from '@solana/errors-core'; export * from './codes'; export * from './error'; export * from './instruction-error'; diff --git a/packages/errors/src/message-formatter.ts b/packages/errors/src/message-formatter.ts index f63ddef13..6efb1aed1 100644 --- a/packages/errors/src/message-formatter.ts +++ b/packages/errors/src/message-formatter.ts @@ -1,95 +1,10 @@ +import { formatMessageTemplate } from '@solana/errors-core'; + import { SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN, SolanaErrorCode } from './codes'; import { SolanaErrorMessages } from './messages'; const INSTRUCTION_ERROR_RANGE_SIZE = 1000; -const enum StateType { - EscapeSequence, - Text, - Variable, -} -type State = Readonly<{ - [START_INDEX]: number; - [TYPE]: StateType; -}>; -const START_INDEX = 'i'; -const TYPE = 't'; - -/** - * Interpolates `$variable` tokens in a message template with values from a context object. - * Tokens with no matching context key are rendered literally; escape a literal `$` with `\\$`. - * - * Shared by {@link getHumanReadableErrorMessage} and {@link createCodedErrorClass}. - * - * @internal - */ -export function formatMessageTemplate(messageFormatString: string | undefined, context: object = {}): string { - if (!messageFormatString) { - return ''; - } - const template: string = messageFormatString; - let state: State; - function commitStateUpTo(endIndex?: number) { - if (state[TYPE] === StateType.Variable) { - const variableName = template.slice(state[START_INDEX] + 1, endIndex); - - fragments.push( - variableName in context - ? // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `${context[variableName as keyof typeof context]}` - : `$${variableName}`, - ); - } else if (state[TYPE] === StateType.Text) { - fragments.push(template.slice(state[START_INDEX], endIndex)); - } - } - const fragments: string[] = []; - template.split('').forEach((char, ii) => { - if (ii === 0) { - state = { - [START_INDEX]: 0, - [TYPE]: - template[0] === '\\' - ? StateType.EscapeSequence - : template[0] === '$' - ? StateType.Variable - : StateType.Text, - }; - return; - } - let nextState; - switch (state[TYPE]) { - case StateType.EscapeSequence: - nextState = { [START_INDEX]: ii, [TYPE]: StateType.Text }; - break; - case StateType.Text: - if (char === '\\') { - nextState = { [START_INDEX]: ii, [TYPE]: StateType.EscapeSequence }; - } else if (char === '$') { - nextState = { [START_INDEX]: ii, [TYPE]: StateType.Variable }; - } - break; - case StateType.Variable: - if (char === '\\') { - nextState = { [START_INDEX]: ii, [TYPE]: StateType.EscapeSequence }; - } else if (char === '$') { - nextState = { [START_INDEX]: ii, [TYPE]: StateType.Variable }; - } else if (!char.match(/\w/)) { - nextState = { [START_INDEX]: ii, [TYPE]: StateType.Text }; - } - break; - } - if (nextState) { - if (state !== nextState) { - commitStateUpTo(ii); - } - state = nextState; - } - }); - commitStateUpTo(); - return fragments.join(''); -} - export function getHumanReadableErrorMessage( code: TErrorCode, context: object = {}, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 967e25118..636cf36df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -527,6 +527,9 @@ importers: packages/errors: dependencies: + '@solana/errors-core': + specifier: workspace:* + version: link:../errors-core chalk: specifier: 5.6.2 version: 5.6.2 @@ -537,6 +540,12 @@ importers: specifier: '>=5.0.0' version: 5.9.3 + packages/errors-core: + dependencies: + typescript: + specifier: '>=5.0.0' + version: 5.9.3 + packages/eslint-config: dependencies: eslint: From 573efbda160c8dd38fb3ddce7cc0656ff3d9a810 Mon Sep 17 00:00:00 2001 From: amilz <85324096+amilz@users.noreply.github.com> Date: Mon, 4 May 2026 10:56:24 -0700 Subject: [PATCH 7/7] chore: preserve original message-formatter test structure Restore the describe('in dev mode') wrapper around the migrated tests so the diff vs upstream is purely the rename to getHumanReadableErrorMessage plus removal of the production-mode block (whose coverage moved to error-test.ts and coded-error-test.ts). No test bodies changed. --- .../src/__tests__/message-formatter-test.ts | 280 +++++++++--------- 1 file changed, 141 insertions(+), 139 deletions(-) diff --git a/packages/errors/src/__tests__/message-formatter-test.ts b/packages/errors/src/__tests__/message-formatter-test.ts index 2d13932bd..ffbb1fced 100644 --- a/packages/errors/src/__tests__/message-formatter-test.ts +++ b/packages/errors/src/__tests__/message-formatter-test.ts @@ -10,152 +10,154 @@ jest.mock('../messages', () => ({ })); describe('getHumanReadableErrorMessage', () => { - it('renders static error messages', () => { - const messagesSpy = jest.spyOn(MessagesModule, 'SolanaErrorMessages', 'get'); - messagesSpy.mockReturnValue({ - // @ts-expect-error Mock error config doesn't conform to exported config. - 123: 'static error message', - }); - const message = getHumanReadableErrorMessage( - // @ts-expect-error Mock error config doesn't conform to exported config. - 123, - ); - expect(message).toBe('static error message'); - }); - it.each([ - { - expected: "Something awful happened: 'bar'. How awful!", - input: "Something $severity happened: '$foo'. How $severity!", - }, - // Literal backslashes, escaped dollar signs - { - expected: 'How \\awful\\ is the $severity?', - input: 'How \\\\$severity\\\\ is the \\$severity?', - }, - // Variable at beginning of sequence - { expected: 'awful times!', input: '$severity times!' }, - // Variable at end of sequence - { expected: "Isn't it awful?", input: "Isn't it $severity?" }, - // Variable in middle of text sequence - { expected: '~awful~', input: '~$severity~' }, - // Variable interpolation with no value in the lookup - { expected: 'Is $thing a sandwich?', input: 'Is $thing a sandwich?' }, - // Variable that has, as a substring, some other value in the lookup - { expected: '$fool', input: '$fool' }, - // Trick for butting a variable up against regular text - { expected: 'barl', input: '$foo\\l' }, - // Escaped variable marker - { expected: "It's the $severity, ya hear?", input: "It's the \\$severity, ya hear?" }, - // Single dollar sign - { expected: ' $ ', input: ' $ ' }, - // Single dollar sign at start - { expected: '$ ', input: '$ ' }, - // Single dollar sign at end - { expected: ' $', input: ' $' }, - // Double dollar sign with legitimate variable name - { expected: ' $bar ', input: ' $$foo ' }, - // Double dollar sign with legitimate variable name at start - { expected: '$bar ', input: '$$foo ' }, - // Double dollar sign with legitimate variable name at end - { expected: ' $bar', input: ' $$foo' }, - // Single escape sequence - { expected: ' ', input: ' \\ ' }, - // Single escape sequence at start - { expected: ' ', input: '\\ ' }, - // Single escape sequence at end - { expected: ' ', input: ' \\' }, - // Double escape sequence - { expected: ' \\ ', input: ' \\\\ ' }, - // Double escape sequence at start - { expected: '\\ ', input: '\\\\ ' }, - // Double escape sequence at end - { expected: ' \\', input: ' \\\\' }, - // Just text - { expected: 'Some unencumbered text.', input: 'Some unencumbered text.' }, - // Empty string - { expected: '', input: '' }, - ])('interpolates variables into the error message format string `"$input"`', ({ input, expected }) => { - const messagesSpy = jest.spyOn(MessagesModule, 'SolanaErrorMessages', 'get'); - messagesSpy.mockReturnValue({ - // @ts-expect-error Mock error config doesn't conform to exported config. - 123: input, + describe('in dev mode', () => { + it('renders static error messages', () => { + const messagesSpy = jest.spyOn(MessagesModule, 'SolanaErrorMessages', 'get'); + messagesSpy.mockReturnValue({ + // @ts-expect-error Mock error config doesn't conform to exported config. + 123: 'static error message', + }); + const message = getHumanReadableErrorMessage( + // @ts-expect-error Mock error config doesn't conform to exported config. + 123, + ); + expect(message).toBe('static error message'); }); - const message = getHumanReadableErrorMessage( - // @ts-expect-error Mock error context doesn't conform to exported context. - 123, - { foo: 'bar', severity: 'awful' }, - ); - expect(message).toBe(expected); - }); - it('interpolates a Uint8Array variable into a error message format string', () => { - const messagesSpy = jest.spyOn(MessagesModule, 'SolanaErrorMessages', 'get'); - messagesSpy.mockReturnValue({ - // @ts-expect-error Mock error config doesn't conform to exported config. - 123: 'Here is some data: $data', + it.each([ + { + expected: "Something awful happened: 'bar'. How awful!", + input: "Something $severity happened: '$foo'. How $severity!", + }, + // Literal backslashes, escaped dollar signs + { + expected: 'How \\awful\\ is the $severity?', + input: 'How \\\\$severity\\\\ is the \\$severity?', + }, + // Variable at beginning of sequence + { expected: 'awful times!', input: '$severity times!' }, + // Variable at end of sequence + { expected: "Isn't it awful?", input: "Isn't it $severity?" }, + // Variable in middle of text sequence + { expected: '~awful~', input: '~$severity~' }, + // Variable interpolation with no value in the lookup + { expected: 'Is $thing a sandwich?', input: 'Is $thing a sandwich?' }, + // Variable that has, as a substring, some other value in the lookup + { expected: '$fool', input: '$fool' }, + // Trick for butting a variable up against regular text + { expected: 'barl', input: '$foo\\l' }, + // Escaped variable marker + { expected: "It's the $severity, ya hear?", input: "It's the \\$severity, ya hear?" }, + // Single dollar sign + { expected: ' $ ', input: ' $ ' }, + // Single dollar sign at start + { expected: '$ ', input: '$ ' }, + // Single dollar sign at end + { expected: ' $', input: ' $' }, + // Double dollar sign with legitimate variable name + { expected: ' $bar ', input: ' $$foo ' }, + // Double dollar sign with legitimate variable name at start + { expected: '$bar ', input: '$$foo ' }, + // Double dollar sign with legitimate variable name at end + { expected: ' $bar', input: ' $$foo' }, + // Single escape sequence + { expected: ' ', input: ' \\ ' }, + // Single escape sequence at start + { expected: ' ', input: '\\ ' }, + // Single escape sequence at end + { expected: ' ', input: ' \\' }, + // Double escape sequence + { expected: ' \\ ', input: ' \\\\ ' }, + // Double escape sequence at start + { expected: '\\ ', input: '\\\\ ' }, + // Double escape sequence at end + { expected: ' \\', input: ' \\\\' }, + // Just text + { expected: 'Some unencumbered text.', input: 'Some unencumbered text.' }, + // Empty string + { expected: '', input: '' }, + ])('interpolates variables into the error message format string `"$input"`', ({ input, expected }) => { + const messagesSpy = jest.spyOn(MessagesModule, 'SolanaErrorMessages', 'get'); + messagesSpy.mockReturnValue({ + // @ts-expect-error Mock error config doesn't conform to exported config. + 123: input, + }); + const message = getHumanReadableErrorMessage( + // @ts-expect-error Mock error context doesn't conform to exported context. + 123, + { foo: 'bar', severity: 'awful' }, + ); + expect(message).toBe(expected); }); - const message = getHumanReadableErrorMessage( - // @ts-expect-error Mock error context doesn't conform to exported context. - 123, - { data: new Uint8Array([1, 2, 3, 4]) }, - ); - expect(message).toBe('Here is some data: 1,2,3,4'); - }); - it('interpolates an undefined variable into a error message format string', () => { - const messagesSpy = jest.spyOn(MessagesModule, 'SolanaErrorMessages', 'get'); - messagesSpy.mockReturnValue({ - // @ts-expect-error Mock error config doesn't conform to exported config. - 123: 'Here is a variable: $variable', + it('interpolates a Uint8Array variable into a error message format string', () => { + const messagesSpy = jest.spyOn(MessagesModule, 'SolanaErrorMessages', 'get'); + messagesSpy.mockReturnValue({ + // @ts-expect-error Mock error config doesn't conform to exported config. + 123: 'Here is some data: $data', + }); + const message = getHumanReadableErrorMessage( + // @ts-expect-error Mock error context doesn't conform to exported context. + 123, + { data: new Uint8Array([1, 2, 3, 4]) }, + ); + expect(message).toBe('Here is some data: 1,2,3,4'); }); - const message = getHumanReadableErrorMessage( - // @ts-expect-error Mock error context doesn't conform to exported context. - 123, - { variable: undefined }, - ); - expect(message).toBe('Here is a variable: undefined'); - }); - it('appends the instruction number to instruction error messages', () => { - const messagesSpy = jest.spyOn(MessagesModule, 'SolanaErrorMessages', 'get'); - // @ts-expect-error Mock error config doesn't conform to exported config. - messagesSpy.mockReturnValue({ - [SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN]: 'Some instruction error', + it('interpolates an undefined variable into a error message format string', () => { + const messagesSpy = jest.spyOn(MessagesModule, 'SolanaErrorMessages', 'get'); + messagesSpy.mockReturnValue({ + // @ts-expect-error Mock error config doesn't conform to exported config. + 123: 'Here is a variable: $variable', + }); + const message = getHumanReadableErrorMessage( + // @ts-expect-error Mock error context doesn't conform to exported context. + 123, + { variable: undefined }, + ); + expect(message).toBe('Here is a variable: undefined'); }); - const message = getHumanReadableErrorMessage(SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN, { index: 0 }); - expect(message).toBe('Some instruction error (instruction #1)'); - }); - it('uses one-based instruction numbering', () => { - const messagesSpy = jest.spyOn(MessagesModule, 'SolanaErrorMessages', 'get'); - // @ts-expect-error Mock error config doesn't conform to exported config. - messagesSpy.mockReturnValue({ - [SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN]: 'Some instruction error', + it('appends the instruction number to instruction error messages', () => { + const messagesSpy = jest.spyOn(MessagesModule, 'SolanaErrorMessages', 'get'); + // @ts-expect-error Mock error config doesn't conform to exported config. + messagesSpy.mockReturnValue({ + [SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN]: 'Some instruction error', + }); + const message = getHumanReadableErrorMessage(SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN, { index: 0 }); + expect(message).toBe('Some instruction error (instruction #1)'); }); - const message = getHumanReadableErrorMessage(SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN, { index: 5 }); - expect(message).toBe('Some instruction error (instruction #6)'); - }); - it('appends the instruction number to error codes at the end of the instruction error range', () => { - const lastInstructionErrorCode = SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN + 999; - const messagesSpy = jest.spyOn(MessagesModule, 'SolanaErrorMessages', 'get'); - // @ts-expect-error Mock error config doesn't conform to exported config. - messagesSpy.mockReturnValue({ - [lastInstructionErrorCode]: 'Some instruction error', + it('uses one-based instruction numbering', () => { + const messagesSpy = jest.spyOn(MessagesModule, 'SolanaErrorMessages', 'get'); + // @ts-expect-error Mock error config doesn't conform to exported config. + messagesSpy.mockReturnValue({ + [SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN]: 'Some instruction error', + }); + const message = getHumanReadableErrorMessage(SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN, { index: 5 }); + expect(message).toBe('Some instruction error (instruction #6)'); }); - const message = getHumanReadableErrorMessage( - // @ts-expect-error Mock error code doesn't conform to exported config. - lastInstructionErrorCode, - { index: 2 }, - ); - expect(message).toBe('Some instruction error (instruction #3)'); - }); - it('does not append the instruction number to non-instruction error messages', () => { - const messagesSpy = jest.spyOn(MessagesModule, 'SolanaErrorMessages', 'get'); - messagesSpy.mockReturnValue({ + it('appends the instruction number to error codes at the end of the instruction error range', () => { + const lastInstructionErrorCode = SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN + 999; + const messagesSpy = jest.spyOn(MessagesModule, 'SolanaErrorMessages', 'get'); // @ts-expect-error Mock error config doesn't conform to exported config. - 123: 'some other error', + messagesSpy.mockReturnValue({ + [lastInstructionErrorCode]: 'Some instruction error', + }); + const message = getHumanReadableErrorMessage( + // @ts-expect-error Mock error code doesn't conform to exported config. + lastInstructionErrorCode, + { index: 2 }, + ); + expect(message).toBe('Some instruction error (instruction #3)'); + }); + it('does not append the instruction number to non-instruction error messages', () => { + const messagesSpy = jest.spyOn(MessagesModule, 'SolanaErrorMessages', 'get'); + messagesSpy.mockReturnValue({ + // @ts-expect-error Mock error config doesn't conform to exported config. + 123: 'some other error', + }); + const message = getHumanReadableErrorMessage( + // @ts-expect-error Mock error context doesn't conform to exported context. + 123, + { index: 0 }, + ); + expect(message).toBe('some other error'); }); - const message = getHumanReadableErrorMessage( - // @ts-expect-error Mock error context doesn't conform to exported context. - 123, - { index: 0 }, - ); - expect(message).toBe('some other error'); }); });