Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/dark-turtles-try.md
Original file line number Diff line number Diff line change
@@ -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).
8 changes: 8 additions & 0 deletions .changeset/fine-snakes-grow.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/slow-queens-argue.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions packages/errors-core/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.docs/
dist/
1 change: 1 addition & 0 deletions packages/errors-core/.npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
engine-strict=true
4 changes: 4 additions & 0 deletions packages/errors-core/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Changelogs are autogenerated, so leave them alone
CHANGELOG.md

dist/
1 change: 1 addition & 0 deletions packages/errors-core/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# @solana/errors-core
20 changes: 20 additions & 0 deletions packages/errors-core/LICENSE
Original file line number Diff line number Diff line change
@@ -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.
132 changes: 132 additions & 0 deletions packages/errors-core/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
[![npm][npm-image]][npm-url]
[![npm-downloads][npm-downloads-image]][npm-url]
<br />
[![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<KoraErrorCode, KoraErrorContext>({
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<Record<TCode, string>>` 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.
87 changes: 87 additions & 0 deletions packages/errors-core/package.json
Original file line number Diff line number Diff line change
@@ -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 <maintainers@solanalabs.com>",
"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"
}
}
4 changes: 4 additions & 0 deletions packages/errors-core/src/.npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
__benchmarks__/
__mocks__/
__tests__/
__typetests__/
Loading
Loading