Skip to content
Open
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
41 changes: 41 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Changelog

All notable changes to the Wraith Protocol SDK will be documented in this file.

## [1.5.0] - 2026-05-31

### Added

- **Typed Error Taxonomy & Hierarchy**: Introduced a robust, typed error hierarchy under `src/errors.ts` (exported from the SDK root entry point) to allow consumers to programmatically handle different error categories without brittle string matching on `error.message`.
- **Base Errors**: `WraithError` (abstract base), `WraithInputError`, `WraithCryptoError`, `WraithNetworkError`, `WraithContractError`, `WraithBuilderError`.
- **Subclass Errors**:
- _Inputs_: `InvalidMetaAddressError`, `InvalidNameError`, `InvalidSignatureError`, `InvalidScalarError`.
- _Cryptography_: `KeyDerivationFailedError`, `ViewTagMismatchError`, `ECDHFailedError`.
- _Network_: `RPCRequestError`, `RPCRetryExhaustedError`, `RetentionExceededError`.
- _Smart Contracts_: `NameNotFoundError`, `NameAlreadyRegisteredError`, `InsufficientAuthError`, `ContractRevertError`.
- _Builders_: `InsufficientBalanceError`, `UnsupportedAssetError`.
- **Serialization Support**: Custom error classes implement `toJSON()` and carry enumerable, public structured context fields, guaranteeing that `JSON.stringify(error)` preserves the stable code constants (e.g. `"WRAITH/CRYPTO/VIEW_TAG_MISMATCH"`), names, messages, and docs links.
- **Reference Documentation Links**: Every error instance now automatically includes a `docsLink` property pointing directly to the detailed error reference page on `https://docs.wraith.dev/sdk/errors`, which is also appended to the human-readable `message`.

### Changed

- **Codebase-wide Custom Error Migration**: Replaced generic JavaScript `Error` instances throughout the codebase (in EVM, Stellar, Solana, and CKB modules) with appropriate typed exceptions.
- **JSDoc Annotations**: Updated JSDoc `@throws` annotations across primary functions to reflect the precise custom error types thrown.

### Migration / Breaking Change Notice

- **Runtime Non-Breaking**: This release is fully backwards-compatible at a runtime level for applications that catch errors as generic JS `Error` instances, since all custom exceptions extend the native `Error` class.
- **Typing-Breaking for Brittle Matchers**: If your application catch blocks rely on exact substring matching against `error.message` (e.g. `if (e.message.includes('Expected 65-byte signature'))`), this change will break those assertions. You should migrate to use:

```typescript
import { InvalidSignatureError } from '@wraith-protocol/sdk';

try {
// ...
} catch (e) {
if (e instanceof InvalidSignatureError) {
// Handle invalid signature specifically with rich structured context
console.log(e.context.expectedLength);
}
}
```
167 changes: 167 additions & 0 deletions docs/errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
# SDK Error Handling Guide

The Wraith Protocol SDK (`@wraith-protocol/sdk`) provides a highly granular, typed hierarchy of custom error classes. This taxonomy allows developers to programmatically catch and handle specific error conditions without having to parse error message strings.

All custom errors extend a base `WraithError` class, which extends the native JavaScript `Error` class, ensuring complete backwards-compatibility.

---

## The Error Hierarchy

All custom exceptions are organized under five major categories:

```
WraithError (Abstract Base)
β”œβ”€β”€ WraithInputError
β”‚ β”œβ”€β”€ InvalidMetaAddressError
β”‚ β”œβ”€β”€ InvalidNameError
β”‚ β”œβ”€β”€ InvalidSignatureError
β”‚ └── InvalidScalarError
β”œβ”€β”€ WraithCryptoError
β”‚ β”œβ”€β”€ KeyDerivationFailedError
β”‚ β”œβ”€β”€ ViewTagMismatchError
β”‚ └── ECDHFailedError
β”œβ”€β”€ WraithNetworkError
β”‚ β”œβ”€β”€ RPCRequestError
β”‚ β”œβ”€β”€ RPCRetryExhaustedError
β”‚ └── RetentionExceededError
β”œβ”€β”€ WraithContractError
β”‚ β”œβ”€β”€ NameNotFoundError
β”‚ β”œβ”€β”€ NameAlreadyRegisteredError
β”‚ β”œβ”€β”€ InsufficientAuthError
β”‚ └── ContractRevertError
└── WraithBuilderError
β”œβ”€β”€ InsufficientBalanceError
└── UnsupportedAssetError
```

---

## Reference & Stable Codes

Each error subclass contains three public properties:

1. `name`: The name of the error class matching its type.
2. `code`: A stable, serializable uppercase constant string (e.g. `"WRAITH/CRYPTO/VIEW_TAG_MISMATCH"`) to preserve identity across execution boundaries (e.g. logging servers or frontend-backend API bridges).
3. `context`: A structured object containing domain-specific context.
4. `docsLink`: A link pointing directly to the reference explanation.

### 1. Input Validation Errors (`WraithInputError`)

Thrown when parameters supplied to SDK functions fail syntactic, length, or boundary validation checks.

| Error Class | Stable Code | Context Fields | Description |
| :------------------------ | :------------------------------------ | :-------------------------------------------- | :------------------------------------------------------------------------------------- |
| `InvalidMetaAddressError` | `"WRAITH/INPUT/INVALID_META_ADDRESS"` | `metaAddress`, `reason` | Thrown when a stealth meta-address has an invalid prefix, length, or points. |
| `InvalidNameError` | `"WRAITH/INPUT/INVALID_NAME"` | `name`, `reason` | Thrown when a `.wraith` name is invalid (e.g. incorrect length, bad characters). |
| `InvalidSignatureError` | `"WRAITH/INPUT/INVALID_SIGNATURE"` | `signature`, `expectedLength`, `actualLength` | Thrown when a cryptographic signature has an incorrect length or invalid format. |
| `InvalidScalarError` | `"WRAITH/INPUT/INVALID_SCALAR"` | `scalar`, `reason` | Thrown when a computed private key scalar is out of valid bounds (e.g. is zero mod n). |

### 2. Cryptographic Errors (`WraithCryptoError`)

Thrown when low-level mathematical operations or elliptic curve calculations fail.

| Error Class | Stable Code | Context Fields | Description |
| :------------------------- | :-------------------------------------- | :------------------------- | :--------------------------------------------------------------------------------------- |
| `KeyDerivationFailedError` | `"WRAITH/CRYPTO/KEY_DERIVATION_FAILED"` | `reason` | Thrown when signature-to-stealth key derivations fail valid curve range checks. |
| `ViewTagMismatchError` | `"WRAITH/CRYPTO/VIEW_TAG_MISMATCH"` | `expectedTag`, `actualTag` | Thrown when scanning announcements if view tag filters don't align. |
| `ECDHFailedError` | `"WRAITH/CRYPTO/ECDH_FAILED"` | `reason` | Thrown when elliptic curve Diffie-Hellman operations fail (e.g. public point off curve). |

### 3. Network & Connection Errors (`WraithNetworkError`)

Thrown when HTTP queries to Wraith APIs, Horizon/Soroban endpoints, Solana clusters, or CKB indexers fail.

| Error Class | Stable Code | Context Fields | Description |
| :----------------------- | :------------------------------------- | :---------------------------------- | :------------------------------------------------------------------------ |
| `RPCRequestError` | `"WRAITH/NETWORK/RPC_REQUEST"` | `url`, `statusCode`, `responseText` | Thrown when an HTTP/RPC endpoint returns a non-2xx status code. |
| `RPCRetryExhaustedError` | `"WRAITH/NETWORK/RPC_RETRY_EXHAUSTED"` | `url`, `attempts`, `lastError` | Thrown when all query retry strategies have timed out or failed. |
| `RetentionExceededError` | `"WRAITH/NETWORK/RETENTION_EXCEEDED"` | `limit`, `actual` | Thrown when querying historical logs beyond maximum retention boundaries. |

### 4. Smart Contract Errors (`WraithContractError`)

Thrown when on-chain interactions or blockchain registries return errors.

| Error Class | Stable Code | Context Fields | Description |
| :--------------------------- | :------------------------------------------ | :------------------- | :---------------------------------------------------------------------------- |
| `NameNotFoundError` | `"WRAITH/CONTRACT/NAME_NOT_FOUND"` | `name` | Thrown when attempting to resolve a `.wraith` name that is not registered. |
| `NameAlreadyRegisteredError` | `"WRAITH/CONTRACT/NAME_ALREADY_REGISTERED"` | `name`, `owner` | Thrown when registering a name that is already owned. |
| `InsufficientAuthError` | `"WRAITH/CONTRACT/INSUFFICIENT_AUTH"` | `required`, `actual` | Thrown when a name update or release transaction is signed by a non-owner. |
| `ContractRevertError` | `"WRAITH/CONTRACT/CONTRACT_REVERT"` | `reason`, `txHash` | Thrown when a contract method call or transaction execution reverts on-chain. |

### 5. Transaction Builder Errors (`WraithBuilderError`)

Thrown during local transaction preparation before submission.

| Error Class | Stable Code | Context Fields | Description |
| :------------------------- | :-------------------------------------- | :---------------------------- | :----------------------------------------------------------------------------------------- |
| `InsufficientBalanceError` | `"WRAITH/BUILDER/INSUFFICIENT_BALANCE"` | `required`, `actual`, `asset` | Thrown when the local wallet balance is insufficient to pay for private transfers or fees. |
| `UnsupportedAssetError` | `"WRAITH/BUILDER/UNSUPPORTED_ASSET"` | `asset`, `chain` | Thrown when trying to build transactions for an asset or chain not supported by the SDK. |

---

## Developer Code Examples

### 1. Granular Catch Block

```typescript
import { Wraith } from '@wraith-protocol/sdk';
import { NameNotFoundError, RPCRequestError } from '@wraith-protocol/sdk';

const wraith = new Wraith({ apiKey: 'wraith_prod_...' });

try {
const agent = await wraith.getAgentByName('nonexistent.wraith');
} catch (error) {
if (error instanceof NameNotFoundError) {
console.error(`Please register "${error.context.name}" before proceeding.`);
} else if (error instanceof RPCRequestError) {
console.error(`API server returned an error: Code ${error.statusCode}`);
} else {
console.error('An unexpected error occurred:', error);
}
}
```

### 2. Category-based Filtering

You can catch broader error classes if you only want to distinguish between validation, cryptographic, or network issues:

```typescript
import { WraithInputError, WraithNetworkError } from '@wraith-protocol/sdk';

try {
// Key derivation or address validation
} catch (error) {
if (error instanceof WraithInputError) {
// Catch-all for InvalidMetaAddress, InvalidSignature, InvalidName, etc.
alert('Please check your input parameters and try again.');
} else if (error instanceof WraithNetworkError) {
alert('Network connection problem. Please verify your connection.');
}
}
```

### 3. Serialization to JSON

When passing errors between execution layers (e.g. returning an error from a serverless background worker to a web client), all custom fields are fully preserved:

```typescript
const error = new InvalidMetaAddressError('st:eth:invalid', 'wrong length');

console.log(JSON.stringify(error, null, 2));
```

**Output:**

```json
{
"name": "InvalidMetaAddressError",
"message": "Invalid stealth meta-address format: \"st:eth:invalid\". wrong length (See https://docs.wraith.dev/sdk/errors#invalid-meta-address)",
"code": "WRAITH/INPUT/INVALID_META_ADDRESS",
"docsLink": "https://docs.wraith.dev/sdk/errors#invalid-meta-address",
"context": {
"metaAddress": "st:eth:invalid",
"reason": "wrong length"
}
}
```
33 changes: 31 additions & 2 deletions src/agent/client.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { RPCRequestError } from '../errors';
import type {
WraithConfig,
AgentConfig,
Expand All @@ -24,7 +25,8 @@ export class Wraith {
}

private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
const res = await fetch(`${this.baseUrl}${path}`, {
const url = `${this.baseUrl}${path}`;
const res = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
Expand All @@ -38,12 +40,19 @@ export class Wraith {

if (!res.ok) {
const error = await res.json().catch(() => ({ message: res.statusText }));
throw new Error(error.message || `HTTP ${res.status}`);
throw new RPCRequestError(url, res.status, error.message || res.statusText);
}

return res.json();
}

/**
* Creates a new stealth address AI agent.
*
* @param config Configuration for the agent name, chains, signature, and wallet.
* @returns A WraithAgent instance to control the new agent.
* @throws {RPCRequestError} If the server returns a non-2xx status code.
*/
async createAgent(config: AgentConfig): Promise<WraithAgent> {
const info = await this.request<AgentInfo>('POST', '/agent/create', config);
return new WraithAgent(this, info);
Expand All @@ -59,16 +68,36 @@ export class Wraith {
});
}

/**
* Retrieves a WraithAgent instance by their associated wallet address.
*
* @param walletAddress The base wallet address of the agent owner.
* @returns A WraithAgent instance.
* @throws {RPCRequestError} If the server returns a non-2xx status code.
*/
async getAgentByWallet(walletAddress: string): Promise<WraithAgent> {
const info = await this.request<AgentInfo>('GET', `/agent/wallet/${walletAddress}`);
return new WraithAgent(this, info);
}

/**
* Retrieves a WraithAgent instance by their registered name (e.g. alice.wraith).
*
* @param name The agent name.
* @returns A WraithAgent instance.
* @throws {RPCRequestError} If the server returns a non-2xx status code.
*/
async getAgentByName(name: string): Promise<WraithAgent> {
const info = await this.request<AgentInfo>('GET', `/agent/info/${name}`);
return new WraithAgent(this, info);
}

/**
* Lists all stealth address AI agents associated with the user's API key.
*
* @returns List of AgentInfo objects.
* @throws {RPCRequestError} If the server returns a non-2xx status code.
*/
async listAgents(): Promise<AgentInfo[]> {
return this.request<AgentInfo[]>('GET', '/agents');
}
Expand Down
4 changes: 3 additions & 1 deletion src/chains/ckb/deployments.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { UnsupportedAssetError } from '../../errors';

export interface CKBChainDeployment {
network: string;
rpcUrl: string;
Expand Down Expand Up @@ -42,7 +44,7 @@ export const DEPLOYMENTS: Record<string, CKBChainDeployment> = {
export function getDeployment(chain: string = 'ckb'): CKBChainDeployment {
const deployment = DEPLOYMENTS[chain];
if (!deployment) {
throw new Error(`No CKB deployment found for chain: ${chain}`);
throw new UnsupportedAssetError('native', chain);
}
return deployment;
}
38 changes: 30 additions & 8 deletions src/chains/ckb/meta-address.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { secp256k1 } from '@noble/curves/secp256k1';
import { toBytes } from 'viem';
import { InvalidMetaAddressError } from '../../errors';
import { META_ADDRESS_PREFIX } from './constants';
import type { HexString, StealthMetaAddress } from './types';

Expand All @@ -16,14 +17,24 @@ export function encodeStealthMetaAddress(
const viewBytes = toBytes(viewingPubKey);

if (spendBytes.length !== 33) {
throw new Error(`Spending public key must be 33 bytes (compressed), got ${spendBytes.length}`);
throw new InvalidMetaAddressError(
'',
`Spending public key must be 33 bytes (compressed), got ${spendBytes.length}`,
);
}
if (viewBytes.length !== 33) {
throw new Error(`Viewing public key must be 33 bytes (compressed), got ${viewBytes.length}`);
throw new InvalidMetaAddressError(
'',
`Viewing public key must be 33 bytes (compressed), got ${viewBytes.length}`,
);
}

secp256k1.ProjectivePoint.fromHex(spendBytes);
secp256k1.ProjectivePoint.fromHex(viewBytes);
try {
secp256k1.ProjectivePoint.fromHex(spendBytes);
secp256k1.ProjectivePoint.fromHex(viewBytes);
} catch (err: any) {
throw new InvalidMetaAddressError('', `Invalid public key points: ${err.message}`);
}

const spendHex = spendingPubKey.slice(2);
const viewHex = viewingPubKey.slice(2);
Expand All @@ -36,22 +47,33 @@ export function encodeStealthMetaAddress(
*/
export function decodeStealthMetaAddress(metaAddress: string): StealthMetaAddress {
if (!metaAddress.startsWith(META_ADDRESS_PREFIX)) {
throw new Error(`Invalid stealth meta-address prefix. Expected "${META_ADDRESS_PREFIX}"`);
throw new InvalidMetaAddressError(
metaAddress,
`Invalid stealth meta-address prefix. Expected "${META_ADDRESS_PREFIX}"`,
);
}

const hex = metaAddress.slice(META_ADDRESS_PREFIX.length);

if (hex.length !== 132) {
throw new Error(
throw new InvalidMetaAddressError(
metaAddress,
`Invalid stealth meta-address length. Expected 132 hex chars after prefix, got ${hex.length}`,
);
}

const spendingPubKey = `0x${hex.slice(0, 66)}` as HexString;
const viewingPubKey = `0x${hex.slice(66)}` as HexString;

secp256k1.ProjectivePoint.fromHex(toBytes(spendingPubKey));
secp256k1.ProjectivePoint.fromHex(toBytes(viewingPubKey));
try {
secp256k1.ProjectivePoint.fromHex(toBytes(spendingPubKey));
secp256k1.ProjectivePoint.fromHex(toBytes(viewingPubKey));
} catch (err: any) {
throw new InvalidMetaAddressError(
metaAddress,
`Invalid public key points inside meta-address: ${err.message}`,
);
}

return {
prefix: META_ADDRESS_PREFIX,
Expand Down
Loading