diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..536dac3 --- /dev/null +++ b/CHANGELOG.md @@ -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); + } + } + ``` diff --git a/docs/errors.md b/docs/errors.md new file mode 100644 index 0000000..76e88c5 --- /dev/null +++ b/docs/errors.md @@ -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" + } +} +``` diff --git a/src/agent/client.ts b/src/agent/client.ts index 663f820..1395448 100644 --- a/src/agent/client.ts +++ b/src/agent/client.ts @@ -1,3 +1,4 @@ +import { RPCRequestError } from '../errors'; import type { WraithConfig, AgentConfig, @@ -24,7 +25,8 @@ export class Wraith { } private async request(method: string, path: string, body?: unknown): Promise { - const res = await fetch(`${this.baseUrl}${path}`, { + const url = `${this.baseUrl}${path}`; + const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json', @@ -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 { const info = await this.request('POST', '/agent/create', config); return new WraithAgent(this, info); @@ -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 { const info = await this.request('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 { const info = await this.request('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 { return this.request('GET', '/agents'); } diff --git a/src/chains/ckb/deployments.ts b/src/chains/ckb/deployments.ts index d04d43e..307403f 100644 --- a/src/chains/ckb/deployments.ts +++ b/src/chains/ckb/deployments.ts @@ -1,3 +1,5 @@ +import { UnsupportedAssetError } from '../../errors'; + export interface CKBChainDeployment { network: string; rpcUrl: string; @@ -42,7 +44,7 @@ export const DEPLOYMENTS: Record = { 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; } diff --git a/src/chains/ckb/meta-address.ts b/src/chains/ckb/meta-address.ts index 5e006fd..be9356c 100644 --- a/src/chains/ckb/meta-address.ts +++ b/src/chains/ckb/meta-address.ts @@ -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'; @@ -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); @@ -36,13 +47,17 @@ 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}`, ); } @@ -50,8 +65,15 @@ export function decodeStealthMetaAddress(metaAddress: string): StealthMetaAddres 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, diff --git a/src/chains/ckb/names.ts b/src/chains/ckb/names.ts index eac3b2e..0ea7152 100644 --- a/src/chains/ckb/names.ts +++ b/src/chains/ckb/names.ts @@ -1,5 +1,6 @@ import { blake2b } from '@noble/hashes/blake2b'; import { toHex, toBytes } from 'viem'; +import { InvalidNameError, InvalidSignatureError, InvalidMetaAddressError } from '../../errors'; import { getDeployment } from './deployments'; import { META_ADDRESS_PREFIX } from './constants'; import type { HexString } from './types'; @@ -14,14 +15,18 @@ const CKB_PERSONALIZATION = new TextEncoder().encode('ckb-default-hash'); * Validates a .wraith name. * Names must be 3-32 characters, lowercase alphanumeric and hyphens only. * - * @throws If the name is invalid. + * @throws {InvalidNameError} If the name is invalid. */ function validateName(name: string): void { if (name.length < 3 || name.length > 32) { - throw new Error(`Name must be between 3 and 32 characters, got ${name.length}`); + throw new InvalidNameError( + name, + `Name must be between 3 and 32 characters, got ${name.length}`, + ); } if (!NAME_PATTERN.test(name)) { - throw new Error( + throw new InvalidNameError( + name, 'Name must contain only lowercase alphanumeric characters and hyphens, and must not start or end with a hyphen', ); } @@ -34,7 +39,7 @@ function validateName(name: string): void { * * @param name The .wraith name to hash. * @returns The 32-byte hash as a 0x-prefixed hex string. - * @throws If the name is invalid. + * @throws {InvalidNameError} If the name is invalid. */ export function hashName(name: string): HexString { validateName(name); @@ -57,6 +62,7 @@ export function hashName(name: string): HexString { * @param params.viewingPubKey The 33-byte compressed viewing public key. * @param params.chain Optional chain identifier for deployment lookup (default: "ckb"). * @returns The type script descriptor and cell data hex. + * @throws {InvalidSignatureError} If spendingPubKey or viewingPubKey length is invalid. */ export function buildRegisterName(params: { name: string; @@ -75,10 +81,10 @@ export function buildRegisterName(params: { const viewBytes = toBytes(viewingPubKey); if (spendBytes.length !== 33) { - throw new Error(`Spending public key must be 33 bytes (compressed), got ${spendBytes.length}`); + throw new InvalidSignatureError(spendingPubKey, 33, spendBytes.length); } if (viewBytes.length !== 33) { - throw new Error(`Viewing public key must be 33 bytes (compressed), got ${viewBytes.length}`); + throw new InvalidSignatureError(viewingPubKey, 33, viewBytes.length); } const dataBytes = new Uint8Array(66); @@ -129,12 +135,15 @@ export function buildResolveName(params: { name: string; chain?: string }): { * * @param data The 0x-prefixed hex string of the cell data (66 bytes = 132 hex chars). * @returns The encoded stealth meta-address in "st:ckb:..." format. - * @throws If the data is not exactly 66 bytes. + * @throws {InvalidMetaAddressError} If the data is not exactly 66 bytes. */ export function metaAddressFromNameData(data: HexString): string { const dataBytes = toBytes(data); if (dataBytes.length !== 66) { - throw new Error(`Name cell data must be exactly 66 bytes, got ${dataBytes.length}`); + throw new InvalidMetaAddressError( + '', + `Name cell data must be exactly 66 bytes, got ${dataBytes.length}`, + ); } const spendHex = toHex(dataBytes.slice(0, 33)).slice(2); diff --git a/src/chains/ckb/stealth.ts b/src/chains/ckb/stealth.ts index 7393d34..a82d0d0 100644 --- a/src/chains/ckb/stealth.ts +++ b/src/chains/ckb/stealth.ts @@ -1,3 +1,4 @@ +import { InvalidScalarError } from '../../errors'; import { secp256k1 } from '@noble/curves/secp256k1'; import { sha256 } from '@noble/hashes/sha256'; import { toHex, toBytes } from 'viem'; @@ -41,7 +42,7 @@ export function generateStealthAddress( const n = secp256k1.CURVE.n; let secretScalar = BigInt(toHex(hashed)) % n; if (secretScalar === 0n) { - throw new Error('Hashed secret reduced to zero mod n'); + throw new InvalidScalarError(secretScalar, 'Hashed secret reduced to zero mod n'); } // P_stealth = K_spend + hashed * G diff --git a/src/chains/evm/deployments.ts b/src/chains/evm/deployments.ts index 1afd9b1..c44511e 100644 --- a/src/chains/evm/deployments.ts +++ b/src/chains/evm/deployments.ts @@ -1,3 +1,4 @@ +import { UnsupportedAssetError } from '../../errors'; import type { HexString } from './types'; export interface EVMChainDeployment { @@ -34,9 +35,7 @@ export const DEPLOYMENTS: Record = { export function getDeployment(chain: string): EVMChainDeployment { const deployment = DEPLOYMENTS[chain]; if (!deployment) { - throw new Error( - `No EVM deployment for "${chain}". Available: ${Object.keys(DEPLOYMENTS).join(', ')}`, - ); + throw new UnsupportedAssetError('native', chain); } return deployment; } diff --git a/src/chains/evm/keys.ts b/src/chains/evm/keys.ts index b2f66a2..ce29fea 100644 --- a/src/chains/evm/keys.ts +++ b/src/chains/evm/keys.ts @@ -1,5 +1,6 @@ import { secp256k1 } from '@noble/curves/secp256k1'; import { keccak256, toHex, toBytes } from 'viem'; +import { InvalidSignatureError, KeyDerivationFailedError } from '../../errors'; import type { HexString, StealthKeys } from './types'; /** @@ -10,12 +11,15 @@ import type { HexString, StealthKeys } from './types'; * - viewingKey = keccak256(s) (bytes 32–63) * * Both keys are validated as non-zero scalars less than the secp256k1 curve order. + * + * @throws {InvalidSignatureError} If signature is not 65 bytes. + * @throws {KeyDerivationFailedError} If derived keys are not valid scalars. */ export function deriveStealthKeys(signature: HexString): StealthKeys { const sigBytes = toBytes(signature); if (sigBytes.length !== 65) { - throw new Error(`Expected 65-byte signature, got ${sigBytes.length} bytes`); + throw new InvalidSignatureError(signature, 65, sigBytes.length); } const r = sigBytes.slice(0, 32); @@ -29,10 +33,10 @@ export function deriveStealthKeys(signature: HexString): StealthKeys { const n = secp256k1.CURVE.n; if (spendingScalar === 0n || spendingScalar >= n) { - throw new Error('Derived spending key is not a valid secp256k1 scalar'); + throw new KeyDerivationFailedError('Derived spending key is not a valid secp256k1 scalar'); } if (viewingScalar === 0n || viewingScalar >= n) { - throw new Error('Derived viewing key is not a valid secp256k1 scalar'); + throw new KeyDerivationFailedError('Derived viewing key is not a valid secp256k1 scalar'); } const spendingPubKey = toHex(secp256k1.getPublicKey(toBytes(spendingKey), true)) as HexString; diff --git a/src/chains/evm/meta-address.ts b/src/chains/evm/meta-address.ts index d725846..bb6cc39 100644 --- a/src/chains/evm/meta-address.ts +++ b/src/chains/evm/meta-address.ts @@ -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'; @@ -7,6 +8,8 @@ import type { HexString, StealthMetaAddress } from './types'; * Encodes spending and viewing public keys into a stealth meta-address string. * * Format: `st:eth:0x` + * + * @throws {InvalidMetaAddressError} If spending or viewing key lengths are invalid. */ export function encodeStealthMetaAddress( spendingPubKey: HexString, @@ -16,14 +19,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); @@ -35,16 +48,22 @@ export function encodeStealthMetaAddress( * Decodes a stealth meta-address string into its component public keys. * * Validates the prefix, length, and that both keys are valid secp256k1 points. + * + * @throws {InvalidMetaAddressError} If meta-address is invalid. */ 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}`, ); } @@ -52,8 +71,15 @@ export function decodeStealthMetaAddress(metaAddress: string): StealthMetaAddres 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, diff --git a/src/chains/evm/names.ts b/src/chains/evm/names.ts index 017e39a..10b4d31 100644 --- a/src/chains/evm/names.ts +++ b/src/chains/evm/names.ts @@ -1,3 +1,4 @@ +import { InvalidMetaAddressError } from '../../errors'; import { secp256k1 } from '@noble/curves/secp256k1'; import { keccak256, toHex, toBytes, encodePacked } from 'viem'; import type { HexString } from './types'; @@ -79,7 +80,7 @@ export function signNameRelease(name: string, spendingKey: HexString): HexString */ export function metaAddressToBytes(metaAddress: string): HexString { if (!metaAddress.startsWith('st:eth:0x')) { - throw new Error('Invalid meta-address format'); + throw new InvalidMetaAddressError(metaAddress, 'Invalid meta-address format'); } return `0x${metaAddress.slice('st:eth:0x'.length)}` as HexString; } diff --git a/src/chains/evm/stealth.ts b/src/chains/evm/stealth.ts index 20deeae..9e5ff1a 100644 --- a/src/chains/evm/stealth.ts +++ b/src/chains/evm/stealth.ts @@ -1,3 +1,4 @@ +import { InvalidScalarError } from '../../errors'; import { secp256k1 } from '@noble/curves/secp256k1'; import { keccak256, toHex, toBytes, getAddress } from 'viem'; import type { HexString, GeneratedStealthAddress } from './types'; @@ -35,7 +36,7 @@ export function generateStealthAddress( const n = secp256k1.CURVE.n; let secretScalar = BigInt(hashedSecret) % n; if (secretScalar === 0n) { - throw new Error('Hashed secret reduced to zero mod n'); + throw new InvalidScalarError(secretScalar, 'Hashed secret reduced to zero mod n'); } const K_spend = secp256k1.ProjectivePoint.fromHex(toBytes(spendingPubKey)); diff --git a/src/chains/solana/builders.ts b/src/chains/solana/builders.ts index b1b95b4..0ada62b 100644 --- a/src/chains/solana/builders.ts +++ b/src/chains/solana/builders.ts @@ -1,4 +1,5 @@ import { sha256 } from '@noble/hashes/sha256'; +import { InvalidMetaAddressError, InvalidNameError } from '../../errors'; import { generateStealthAddress } from './stealth'; import { decodeStealthMetaAddress } from './meta-address'; import { getDeployment } from './deployments'; @@ -145,7 +146,10 @@ export function buildRegisterName(params: { const cleanName = name.replace(/\.wraith$/, ''); if (metaAddress.length !== 64) { - throw new Error('Meta-address must be 64 bytes (spending_pub || viewing_pub)'); + throw new InvalidMetaAddressError( + '', + 'Meta-address must be 64 bytes (spending_pub || viewing_pub)', + ); } const pda = derivePDA(cleanName, deployment.contracts.names); @@ -197,7 +201,10 @@ export function buildUpdateName(params: { const cleanName = name.replace(/\.wraith$/, ''); if (newMetaAddress.length !== 64) { - throw new Error('Meta-address must be 64 bytes (spending_pub || viewing_pub)'); + throw new InvalidMetaAddressError( + '', + 'Meta-address must be 64 bytes (spending_pub || viewing_pub)', + ); } const pda = derivePDA(cleanName, deployment.contracts.names); @@ -330,7 +337,7 @@ function derivePDA(name: string, programId: string): string { return base58Encode(result); } - throw new Error(`Could not find valid PDA bump for name: ${name}`); + throw new InvalidNameError(name, 'Could not find valid PDA bump for name'); } function base58Decode(str: string): Uint8Array { @@ -338,7 +345,7 @@ function base58Decode(str: string): Uint8Array { let result = 0n; for (const char of str) { const idx = ALPHABET.indexOf(char); - if (idx === -1) throw new Error(`Invalid base58 character: ${char}`); + if (idx === -1) throw new InvalidMetaAddressError(str, `Invalid base58 character: ${char}`); result = result * 58n + BigInt(idx); } const hex = result.toString(16).padStart(64, '0'); diff --git a/src/chains/solana/deployments.ts b/src/chains/solana/deployments.ts index 1cefe43..1dd981d 100644 --- a/src/chains/solana/deployments.ts +++ b/src/chains/solana/deployments.ts @@ -1,3 +1,5 @@ +import { UnsupportedAssetError } from '../../errors'; + export interface SolanaChainDeployment { cluster: string; rpcUrl: string; @@ -25,9 +27,7 @@ export const DEPLOYMENTS: Record = { export function getDeployment(chain: string): SolanaChainDeployment { const deployment = DEPLOYMENTS[chain]; if (!deployment) { - throw new Error( - `No Solana deployment for "${chain}". Available: ${Object.keys(DEPLOYMENTS).join(', ')}`, - ); + throw new UnsupportedAssetError('native', chain); } return deployment; } diff --git a/src/chains/solana/meta-address.ts b/src/chains/solana/meta-address.ts index 90bc944..267d171 100644 --- a/src/chains/solana/meta-address.ts +++ b/src/chains/solana/meta-address.ts @@ -1,4 +1,5 @@ import { ed25519 } from '@noble/curves/ed25519'; +import { InvalidMetaAddressError } from '../../errors'; import { META_ADDRESS_PREFIX } from './constants'; import type { StealthMetaAddress } from './types'; import { bytesToHex, hexToBytes } from './utils'; @@ -7,23 +8,31 @@ import { bytesToHex, hexToBytes } from './utils'; * Encodes spending and viewing public keys into a stealth meta-address string. * * Format: `st:sol:` + * + * @throws {InvalidMetaAddressError} If spending or viewing key lengths are invalid. */ export function encodeStealthMetaAddress( spendingPubKey: Uint8Array, viewingPubKey: Uint8Array, ): string { if (spendingPubKey.length !== 32) { - throw new Error(`Spending public key must be 32 bytes, got ${spendingPubKey.length}`); + throw new InvalidMetaAddressError( + '', + `Spending public key must be 32 bytes, got ${spendingPubKey.length}`, + ); } if (viewingPubKey.length !== 32) { - throw new Error(`Viewing public key must be 32 bytes, got ${viewingPubKey.length}`); + throw new InvalidMetaAddressError( + '', + `Viewing public key must be 32 bytes, got ${viewingPubKey.length}`, + ); } try { ed25519.ExtendedPoint.fromHex(spendingPubKey); ed25519.ExtendedPoint.fromHex(viewingPubKey); - } catch { - throw new Error('Invalid ed25519 public key'); + } catch (err: any) { + throw new InvalidMetaAddressError('', `Invalid ed25519 public key: ${err.message}`); } return `${META_ADDRESS_PREFIX}${bytesToHex(spendingPubKey)}${bytesToHex(viewingPubKey)}`; @@ -33,16 +42,22 @@ export function encodeStealthMetaAddress( * Decodes a stealth meta-address string into its component public keys. * * Validates the prefix, length, and that both keys are valid ed25519 points. + * + * @throws {InvalidMetaAddressError} If meta-address is invalid. */ 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 !== 128) { - throw new Error( + throw new InvalidMetaAddressError( + metaAddress, `Invalid stealth meta-address length. Expected 128 hex chars after prefix, got ${hex.length}`, ); } @@ -53,8 +68,11 @@ export function decodeStealthMetaAddress(metaAddress: string): StealthMetaAddres try { ed25519.ExtendedPoint.fromHex(spendingPubKey); ed25519.ExtendedPoint.fromHex(viewingPubKey); - } catch { - throw new Error('Invalid ed25519 public key in meta-address'); + } catch (err: any) { + throw new InvalidMetaAddressError( + metaAddress, + `Invalid ed25519 public key in meta-address: ${err.message}`, + ); } return { diff --git a/src/chains/stellar/deployments.ts b/src/chains/stellar/deployments.ts index 888c1e9..178a3bd 100644 --- a/src/chains/stellar/deployments.ts +++ b/src/chains/stellar/deployments.ts @@ -1,11 +1,5 @@ -/** - * Network endpoints and contract IDs for a Wraith Stellar deployment. - * - * Use this when building integrations that need to submit Stellar transactions, - * query Soroban events, or call the announcer and names contracts directly. - * - * @see {@link getDeployment} - */ +import { UnsupportedAssetError } from '../../errors'; + export interface StellarChainDeployment { /** Human-readable network name, for example `testnet`. */ network: string; @@ -73,9 +67,7 @@ export const DEPLOYMENTS: Record = { export function getDeployment(chain: string): StellarChainDeployment { const deployment = DEPLOYMENTS[chain]; if (!deployment) { - throw new Error( - `No Stellar deployment for "${chain}". Available: ${Object.keys(DEPLOYMENTS).join(', ')}`, - ); + throw new UnsupportedAssetError('native', chain); } return deployment; } diff --git a/src/chains/stellar/keys.ts b/src/chains/stellar/keys.ts index 92e6162..c6698cd 100644 --- a/src/chains/stellar/keys.ts +++ b/src/chains/stellar/keys.ts @@ -1,3 +1,4 @@ +import { InvalidSignatureError } from '../../errors'; import { ed25519 } from '@noble/curves/ed25519'; import { sha256 } from '@noble/hashes/sha256'; import type { StealthKeys } from './types'; @@ -10,27 +11,15 @@ import { seedToScalar } from './scalar'; * The result is deterministic for the same wallet and signature message, so * keep the returned seeds and scalars private. * - * @param signature - 64-byte ed25519 signature produced by the user's Stellar wallet. - * @returns Spending and viewing seeds, scalars, and public keys for Stellar stealth payments. - * @throws {Error} If `signature` is not exactly 64 bytes. + * Each seed is then expanded via SHA-512 and clamped to produce + * the actual ed25519 scalar (matching how standard ed25519 derives + * the private scalar from a seed). * - * @example - * ```ts - * import { Keypair } from "@stellar/stellar-sdk"; - * import { deriveStealthKeys, STEALTH_SIGNING_MESSAGE } from "@wraith-protocol/sdk/chains/stellar"; - * - * const keypair = Keypair.random(); - * const signature = keypair.sign(Buffer.from(STEALTH_SIGNING_MESSAGE)); - * const keys = deriveStealthKeys(signature); - * - * console.log(keys.spendingPubKey, keys.viewingPubKey); - * ``` - * - * @see {@link encodeStealthMetaAddress} to publish the public keys for senders. + * @throws {InvalidSignatureError} If signature length is not 64. */ export function deriveStealthKeys(signature: Uint8Array): StealthKeys { if (signature.length !== 64) { - throw new Error(`Expected 64-byte ed25519 signature, got ${signature.length} bytes`); + throw new InvalidSignatureError(signature, 64, signature.length); } const spendingPrefix = new TextEncoder().encode('wraith:spending:'); diff --git a/src/chains/stellar/meta-address.ts b/src/chains/stellar/meta-address.ts index 0eee6d9..ccbcf77 100644 --- a/src/chains/stellar/meta-address.ts +++ b/src/chains/stellar/meta-address.ts @@ -1,4 +1,5 @@ import { ed25519 } from '@noble/curves/ed25519'; +import { InvalidMetaAddressError } from '../../errors'; import { META_ADDRESS_PREFIX } from './constants'; import type { StealthMetaAddress } from './types'; import { bytesToHex, hexToBytes } from './utils'; @@ -6,40 +7,32 @@ import { bytesToHex, hexToBytes } from './utils'; /** * Encodes Stellar spending and viewing public keys into a stealth meta-address. * - * Share this value with senders or name registries. It contains only public - * keys, but it should still be treated as a stable recipient identifier. + * Format: `st:xlm:` * - * @param spendingPubKey - Recipient's 32-byte ed25519 spending public key. - * @param viewingPubKey - Recipient's 32-byte ed25519 viewing public key. - * @returns Meta-address in `st:xlm:` format. - * @throws {Error} If either key is not 32 bytes or is not a valid ed25519 public key. - * - * @example - * ```ts - * import { deriveStealthKeys, encodeStealthMetaAddress } from "@wraith-protocol/sdk/chains/stellar"; - * - * const keys = deriveStealthKeys(signature); - * const metaAddress = encodeStealthMetaAddress(keys.spendingPubKey, keys.viewingPubKey); - * ``` - * - * @see {@link decodeStealthMetaAddress} + * @throws {InvalidMetaAddressError} If spending or viewing key lengths are invalid. */ export function encodeStealthMetaAddress( spendingPubKey: Uint8Array, viewingPubKey: Uint8Array, ): string { if (spendingPubKey.length !== 32) { - throw new Error(`Spending public key must be 32 bytes, got ${spendingPubKey.length}`); + throw new InvalidMetaAddressError( + '', + `Spending public key must be 32 bytes, got ${spendingPubKey.length}`, + ); } if (viewingPubKey.length !== 32) { - throw new Error(`Viewing public key must be 32 bytes, got ${viewingPubKey.length}`); + throw new InvalidMetaAddressError( + '', + `Viewing public key must be 32 bytes, got ${viewingPubKey.length}`, + ); } try { ed25519.ExtendedPoint.fromHex(spendingPubKey); ed25519.ExtendedPoint.fromHex(viewingPubKey); - } catch { - throw new Error('Invalid ed25519 public key'); + } catch (err: any) { + throw new InvalidMetaAddressError('', `Invalid ed25519 public key: ${err.message}`); } return `${META_ADDRESS_PREFIX}${bytesToHex(spendingPubKey)}${bytesToHex(viewingPubKey)}`; @@ -63,17 +56,23 @@ export function encodeStealthMetaAddress( * const payment = generateStealthAddress(spendingPubKey, viewingPubKey); * ``` * - * @see {@link encodeStealthMetaAddress} + * Validates the prefix, length, and that both keys are valid ed25519 points. + * + * @throws {InvalidMetaAddressError} If meta-address is invalid. */ 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 !== 128) { - throw new Error( + throw new InvalidMetaAddressError( + metaAddress, `Invalid stealth meta-address length. Expected 128 hex chars after prefix, got ${hex.length}`, ); } @@ -84,8 +83,11 @@ export function decodeStealthMetaAddress(metaAddress: string): StealthMetaAddres try { ed25519.ExtendedPoint.fromHex(spendingPubKey); ed25519.ExtendedPoint.fromHex(viewingPubKey); - } catch { - throw new Error('Invalid ed25519 public key in meta-address'); + } catch (err: any) { + throw new InvalidMetaAddressError( + metaAddress, + `Invalid ed25519 public key in meta-address: ${err.message}`, + ); } return { diff --git a/src/chains/stellar/utils.ts b/src/chains/stellar/utils.ts index 90df0e7..145b719 100644 --- a/src/chains/stellar/utils.ts +++ b/src/chains/stellar/utils.ts @@ -1,3 +1,5 @@ +import { InvalidMetaAddressError } from '../../errors'; + /** * Converts bytes to a lowercase hex string without a `0x` prefix. * @@ -45,7 +47,7 @@ export function bytesToHex(bytes: Uint8Array): string { export function hexToBytes(hex: string): Uint8Array { const clean = hex.startsWith('0x') ? hex.slice(2) : hex; if (clean.length % 2 !== 0) { - throw new Error('Invalid hex string length'); + throw new InvalidMetaAddressError('', 'Invalid hex string length'); } const bytes = new Uint8Array(clean.length / 2); for (let i = 0; i < bytes.length; i++) { diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..bb63d72 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,230 @@ +const DOCS_BASE_URL = 'https://docs.wraith.dev/sdk/errors'; + +export abstract class WraithError extends Error { + abstract readonly code: string; + readonly docsLink: string; + + constructor( + message: string, + public readonly context?: Record, + ) { + super(message); + + // Set prototype explicitly for correct instanceof behavior in ES5/older environments + Object.setPrototypeOf(this, new.target.prototype); + + this.name = this.constructor.name; + + const anchor = this.constructor.name + .replace(/Error$/, '') + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') + .toLowerCase(); + this.docsLink = `${DOCS_BASE_URL}#${anchor}`; + + // Overwrite the message to include the docs link + this.message = `${message} (See ${this.docsLink})`; + } + + toJSON() { + return { + name: this.name, + message: this.message, + code: this.code, + docsLink: this.docsLink, + context: this.context, + }; + } +} + +// Intermediary Base Error Classes +export abstract class WraithInputError extends WraithError {} +export abstract class WraithCryptoError extends WraithError {} +export abstract class WraithNetworkError extends WraithError {} +export abstract class WraithContractError extends WraithError {} +export abstract class WraithBuilderError extends WraithError {} + +// WraithInputError Subclasses +export class InvalidMetaAddressError extends WraithInputError { + readonly code = 'WRAITH/INPUT/INVALID_META_ADDRESS'; + + constructor(metaAddress: string, reason?: string) { + super(`Invalid stealth meta-address format: "${metaAddress}"${reason ? `. ${reason}` : ''}`, { + metaAddress, + reason, + }); + } +} + +export class InvalidNameError extends WraithInputError { + readonly code = 'WRAITH/INPUT/INVALID_NAME'; + + constructor(name: string, reason?: string) { + super(`Invalid name: "${name}"${reason ? `. ${reason}` : ''}`, { name, reason }); + } +} + +export class InvalidSignatureError extends WraithInputError { + readonly code = 'WRAITH/INPUT/INVALID_SIGNATURE'; + + constructor(signature: string | Uint8Array, expectedLength?: number, actualLength?: number) { + const sigStr = typeof signature === 'string' ? signature : '[Uint8Array]'; + super( + `Invalid signature length or format: "${sigStr}"${ + expectedLength !== undefined && actualLength !== undefined + ? `. Expected ${expectedLength} bytes, got ${actualLength}` + : '' + }`, + { signature: sigStr, expectedLength, actualLength }, + ); + } +} + +export class InvalidScalarError extends WraithInputError { + readonly code = 'WRAITH/INPUT/INVALID_SCALAR'; + + constructor(scalar: string | bigint, reason?: string) { + super(`Invalid cryptographic scalar: "${scalar.toString()}"${reason ? `. ${reason}` : ''}`, { + scalar: scalar.toString(), + reason, + }); + } +} + +// WraithCryptoError Subclasses +export class KeyDerivationFailedError extends WraithCryptoError { + readonly code = 'WRAITH/CRYPTO/KEY_DERIVATION_FAILED'; + + constructor(reason: string) { + super(`Key derivation failed: ${reason}`, { reason }); + } +} + +export class ViewTagMismatchError extends WraithCryptoError { + readonly code = 'WRAITH/CRYPTO/VIEW_TAG_MISMATCH'; + + constructor(expectedTag: number, actualTag: number) { + super(`View tag mismatch. Expected ${expectedTag}, got ${actualTag}`, { + expectedTag, + actualTag, + }); + } +} + +export class ECDHFailedError extends WraithCryptoError { + readonly code = 'WRAITH/CRYPTO/ECDH_FAILED'; + + constructor(reason: string) { + super(`Elliptic Curve Diffie-Hellman (ECDH) operation failed: ${reason}`, { reason }); + } +} + +// WraithNetworkError Subclasses +export class RPCRequestError extends WraithNetworkError { + readonly code = 'WRAITH/NETWORK/RPC_REQUEST'; + readonly statusCode: number; + + constructor(url: string, statusCode: number, responseText?: string) { + super( + `RPC request failed to "${url}" with status ${statusCode}${responseText ? `: ${responseText}` : ''}`, + { + url, + statusCode, + responseText, + }, + ); + this.statusCode = statusCode; + } +} + +export class RPCRetryExhaustedError extends WraithNetworkError { + readonly code = 'WRAITH/NETWORK/RPC_RETRY_EXHAUSTED'; + + constructor(url: string, attempts: number, lastError?: string) { + super( + `RPC request retries exhausted for "${url}" after ${attempts} attempts${ + lastError ? `. Last error: ${lastError}` : '' + }`, + { url, attempts, lastError }, + ); + } +} + +export class RetentionExceededError extends WraithNetworkError { + readonly code = 'WRAITH/NETWORK/RETENTION_EXCEEDED'; + + constructor(limit: number, actual: number) { + super(`Retention limit exceeded. Max allowed is ${limit}, actual is ${actual}`, { + limit, + actual, + }); + } +} + +// WraithContractError Subclasses +export class NameNotFoundError extends WraithContractError { + readonly code = 'WRAITH/CONTRACT/NAME_NOT_FOUND'; + + constructor(name: string) { + super(`Name not found: "${name}"`, { name }); + } +} + +export class NameAlreadyRegisteredError extends WraithContractError { + readonly code = 'WRAITH/CONTRACT/NAME_ALREADY_REGISTERED'; + + constructor(name: string, owner?: string) { + super(`Name is already registered: "${name}"${owner ? ` (owner: ${owner})` : ''}`, { + name, + owner, + }); + } +} + +export class InsufficientAuthError extends WraithContractError { + readonly code = 'WRAITH/CONTRACT/INSUFFICIENT_AUTH'; + + constructor(required?: string, actual?: string) { + super( + `Insufficient authority to perform operation${ + required && actual ? `. Required: ${required}, actual: ${actual}` : '' + }`, + { required, actual }, + ); + } +} + +export class ContractRevertError extends WraithContractError { + readonly code = 'WRAITH/CONTRACT/CONTRACT_REVERT'; + readonly reason: string; + + constructor(reason: string, txHash?: string) { + super(`Smart contract transaction reverted: ${reason}${txHash ? ` (txHash: ${txHash})` : ''}`, { + reason, + txHash, + }); + this.reason = reason; + } +} + +// WraithBuilderError Subclasses +export class InsufficientBalanceError extends WraithBuilderError { + readonly code = 'WRAITH/BUILDER/INSUFFICIENT_BALANCE'; + + constructor(required: string | bigint, actual: string | bigint, asset?: string) { + super( + `Insufficient balance to build transaction${asset ? ` for ${asset}` : ''}. Required: ${required.toString()}, actual: ${actual.toString()}`, + { required: required.toString(), actual: actual.toString(), asset }, + ); + } +} + +export class UnsupportedAssetError extends WraithBuilderError { + readonly code = 'WRAITH/BUILDER/UNSUPPORTED_ASSET'; + + constructor(asset: string, chain?: string) { + super(`Asset "${asset}" is not supported${chain ? ` on chain ${chain}` : ''}`, { + asset, + chain, + }); + } +} diff --git a/src/index.ts b/src/index.ts index 295d4b4..6298de4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,15 +17,27 @@ export type { Conversation, } from './agent/types'; -export { MultichainScannerPool } from './scanner-pool'; -export type { - SupportedChain, - ScanInput, - EvmScanInput, - StellarScanInput, - SolanaScanInput, - CkbScanInput, - ScanResults, - ProgressEvent, - MultichainScannerPoolOptions, -} from './scanner-pool'; +export { + WraithError, + WraithInputError, + WraithCryptoError, + WraithNetworkError, + WraithContractError, + WraithBuilderError, + InvalidMetaAddressError, + InvalidNameError, + InvalidSignatureError, + InvalidScalarError, + KeyDerivationFailedError, + ViewTagMismatchError, + ECDHFailedError, + RPCRequestError, + RPCRetryExhaustedError, + RetentionExceededError, + NameNotFoundError, + NameAlreadyRegisteredError, + InsufficientAuthError, + ContractRevertError, + InsufficientBalanceError, + UnsupportedAssetError, +} from './errors'; diff --git a/test/chains/ckb/keys.test.ts b/test/chains/ckb/keys.test.ts index e10e656..20ebe22 100644 --- a/test/chains/ckb/keys.test.ts +++ b/test/chains/ckb/keys.test.ts @@ -1,3 +1,4 @@ +import { InvalidSignatureError } from '../../../src/errors'; import { describe, test, expect } from 'vitest'; import { deriveStealthKeys } from '../../../src/chains/ckb/keys'; import type { HexString } from '../../../src/chains/ckb/types'; @@ -29,6 +30,6 @@ describe('deriveStealthKeys', () => { test('rejects wrong signature length', () => { const short = ('0x' + 'aa'.repeat(64)) as HexString; - expect(() => deriveStealthKeys(short)).toThrow('Expected 65-byte signature'); + expect(() => deriveStealthKeys(short)).toThrow(InvalidSignatureError); }); }); diff --git a/test/chains/evm/keys.test.ts b/test/chains/evm/keys.test.ts index d44803c..71be06f 100644 --- a/test/chains/evm/keys.test.ts +++ b/test/chains/evm/keys.test.ts @@ -1,3 +1,4 @@ +import { InvalidSignatureError } from '../../../src/errors'; import { describe, test, expect } from 'vitest'; import { deriveStealthKeys } from '../../../src/chains/evm/keys'; import type { HexString } from '../../../src/chains/evm/types'; @@ -31,11 +32,11 @@ describe('deriveStealthKeys', () => { test('rejects wrong signature length (64 bytes)', () => { const short = ('0x' + 'aa'.repeat(64)) as HexString; - expect(() => deriveStealthKeys(short)).toThrow('Expected 65-byte signature'); + expect(() => deriveStealthKeys(short)).toThrow(InvalidSignatureError); }); test('rejects wrong signature length (66 bytes)', () => { const long = ('0x' + 'aa'.repeat(66)) as HexString; - expect(() => deriveStealthKeys(long)).toThrow('Expected 65-byte signature'); + expect(() => deriveStealthKeys(long)).toThrow(InvalidSignatureError); }); }); diff --git a/test/chains/solana/keys.test.ts b/test/chains/solana/keys.test.ts index e022400..ce50959 100644 --- a/test/chains/solana/keys.test.ts +++ b/test/chains/solana/keys.test.ts @@ -1,3 +1,4 @@ +import { InvalidSignatureError } from '../../../src/errors'; import { describe, test, expect } from 'vitest'; import { deriveStealthKeys } from '../../../src/chains/solana/keys'; @@ -44,11 +45,11 @@ describe('deriveStealthKeys', () => { test('rejects wrong signature length (63 bytes)', () => { const short = new Uint8Array(63).fill(0xaa); - expect(() => deriveStealthKeys(short)).toThrow('Expected 64-byte'); + expect(() => deriveStealthKeys(short)).toThrow(InvalidSignatureError); }); test('rejects wrong signature length (65 bytes)', () => { const long = new Uint8Array(65).fill(0xaa); - expect(() => deriveStealthKeys(long)).toThrow('Expected 64-byte'); + expect(() => deriveStealthKeys(long)).toThrow(InvalidSignatureError); }); }); diff --git a/test/chains/stellar/keys.test.ts b/test/chains/stellar/keys.test.ts index 6c167b0..7dedd6a 100644 --- a/test/chains/stellar/keys.test.ts +++ b/test/chains/stellar/keys.test.ts @@ -1,3 +1,4 @@ +import { InvalidSignatureError } from '../../../src/errors'; import { describe, test, expect } from 'vitest'; import { deriveStealthKeys } from '../../../src/chains/stellar/keys'; @@ -45,11 +46,11 @@ describe('deriveStealthKeys', () => { test('rejects wrong signature length (63 bytes)', () => { const short = new Uint8Array(63).fill(0xaa); - expect(() => deriveStealthKeys(short)).toThrow('Expected 64-byte'); + expect(() => deriveStealthKeys(short)).toThrow(InvalidSignatureError); }); test('rejects wrong signature length (65 bytes)', () => { const long = new Uint8Array(65).fill(0xaa); - expect(() => deriveStealthKeys(long)).toThrow('Expected 64-byte'); + expect(() => deriveStealthKeys(long)).toThrow(InvalidSignatureError); }); }); diff --git a/test/errors.test.ts b/test/errors.test.ts new file mode 100644 index 0000000..9a2da8a --- /dev/null +++ b/test/errors.test.ts @@ -0,0 +1,235 @@ +import { describe, test, expect } from 'vitest'; +import { + WraithError, + WraithInputError, + WraithCryptoError, + WraithNetworkError, + WraithContractError, + WraithBuilderError, + InvalidMetaAddressError, + InvalidNameError, + InvalidSignatureError, + InvalidScalarError, + KeyDerivationFailedError, + ViewTagMismatchError, + ECDHFailedError, + RPCRequestError, + RPCRetryExhaustedError, + RetentionExceededError, + NameNotFoundError, + NameAlreadyRegisteredError, + InsufficientAuthError, + ContractRevertError, + InsufficientBalanceError, + UnsupportedAssetError, +} from '../src/errors'; + +describe('Wraith Custom Errors Taxonomy', () => { + // Test 1: Validate instanceof chain for WraithInputError and its subclasses + test('InvalidMetaAddressError instanceof checks', () => { + const error = new InvalidMetaAddressError('st:eth:0x123', 'bad length'); + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(WraithError); + expect(error).toBeInstanceOf(WraithInputError); + expect(error).toBeInstanceOf(InvalidMetaAddressError); + expect(error.code).toBe('WRAITH/INPUT/INVALID_META_ADDRESS'); + expect(error.name).toBe('InvalidMetaAddressError'); + expect(error.context).toEqual({ metaAddress: 'st:eth:0x123', reason: 'bad length' }); + expect(error.docsLink).toBe('https://docs.wraith.dev/sdk/errors#invalid-meta-address'); + expect(error.message).toContain('Invalid stealth meta-address format'); + expect(error.message).toContain(error.docsLink); + }); + + test('InvalidNameError instanceof checks', () => { + const error = new InvalidNameError('alice.wraith', 'too short'); + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(WraithError); + expect(error).toBeInstanceOf(WraithInputError); + expect(error).toBeInstanceOf(InvalidNameError); + expect(error.code).toBe('WRAITH/INPUT/INVALID_NAME'); + expect(error.name).toBe('InvalidNameError'); + expect(error.context).toEqual({ name: 'alice.wraith', reason: 'too short' }); + }); + + test('InvalidSignatureError instanceof checks', () => { + const error = new InvalidSignatureError('0xabc', 65, 3); + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(WraithError); + expect(error).toBeInstanceOf(WraithInputError); + expect(error).toBeInstanceOf(InvalidSignatureError); + expect(error.code).toBe('WRAITH/INPUT/INVALID_SIGNATURE'); + expect(error.name).toBe('InvalidSignatureError'); + expect(error.context).toEqual({ signature: '0xabc', expectedLength: 65, actualLength: 3 }); + }); + + test('InvalidScalarError instanceof checks', () => { + const error = new InvalidScalarError(0n, 'is zero'); + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(WraithError); + expect(error).toBeInstanceOf(WraithInputError); + expect(error).toBeInstanceOf(InvalidScalarError); + expect(error.code).toBe('WRAITH/INPUT/INVALID_SCALAR'); + expect(error.name).toBe('InvalidScalarError'); + expect(error.context).toEqual({ scalar: '0', reason: 'is zero' }); + }); + + // Test 2: Validate instanceof chain for WraithCryptoError and its subclasses + test('KeyDerivationFailedError instanceof checks', () => { + const error = new KeyDerivationFailedError('invalid scalar addition'); + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(WraithError); + expect(error).toBeInstanceOf(WraithCryptoError); + expect(error).toBeInstanceOf(KeyDerivationFailedError); + expect(error.code).toBe('WRAITH/CRYPTO/KEY_DERIVATION_FAILED'); + expect(error.name).toBe('KeyDerivationFailedError'); + expect(error.context).toEqual({ reason: 'invalid scalar addition' }); + }); + + test('ViewTagMismatchError instanceof checks', () => { + const error = new ViewTagMismatchError(42, 24); + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(WraithError); + expect(error).toBeInstanceOf(WraithCryptoError); + expect(error).toBeInstanceOf(ViewTagMismatchError); + expect(error.code).toBe('WRAITH/CRYPTO/VIEW_TAG_MISMATCH'); + expect(error.name).toBe('ViewTagMismatchError'); + expect(error.context).toEqual({ expectedTag: 42, actualTag: 24 }); + }); + + test('ECDHFailedError instanceof checks', () => { + const error = new ECDHFailedError('point off curve'); + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(WraithError); + expect(error).toBeInstanceOf(WraithCryptoError); + expect(error).toBeInstanceOf(ECDHFailedError); + expect(error.code).toBe('WRAITH/CRYPTO/ECDH_FAILED'); + expect(error.name).toBe('ECDHFailedError'); + expect(error.context).toEqual({ reason: 'point off curve' }); + }); + + // Test 3: Validate instanceof chain for WraithNetworkError and its subclasses + test('RPCRequestError instanceof checks', () => { + const error = new RPCRequestError('https://horizon.stellar.org', 404, 'Not Found'); + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(WraithError); + expect(error).toBeInstanceOf(WraithNetworkError); + expect(error).toBeInstanceOf(RPCRequestError); + expect(error.code).toBe('WRAITH/NETWORK/RPC_REQUEST'); + expect(error.name).toBe('RPCRequestError'); + expect(error.statusCode).toBe(404); + expect(error.context).toEqual({ + url: 'https://horizon.stellar.org', + statusCode: 404, + responseText: 'Not Found', + }); + }); + + test('RPCRetryExhaustedError instanceof checks', () => { + const error = new RPCRetryExhaustedError('https://horizon.stellar.org', 5, 'timeout'); + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(WraithError); + expect(error).toBeInstanceOf(WraithNetworkError); + expect(error).toBeInstanceOf(RPCRetryExhaustedError); + expect(error.code).toBe('WRAITH/NETWORK/RPC_RETRY_EXHAUSTED'); + expect(error.name).toBe('RPCRetryExhaustedError'); + expect(error.context).toEqual({ + url: 'https://horizon.stellar.org', + attempts: 5, + lastError: 'timeout', + }); + }); + + test('RetentionExceededError instanceof checks', () => { + const error = new RetentionExceededError(100, 105); + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(WraithError); + expect(error).toBeInstanceOf(WraithNetworkError); + expect(error).toBeInstanceOf(RetentionExceededError); + expect(error.code).toBe('WRAITH/NETWORK/RETENTION_EXCEEDED'); + expect(error.name).toBe('RetentionExceededError'); + expect(error.context).toEqual({ limit: 100, actual: 105 }); + }); + + // Test 4: Validate instanceof chain for WraithContractError and its subclasses + test('NameNotFoundError instanceof checks', () => { + const error = new NameNotFoundError('missing.wraith'); + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(WraithError); + expect(error).toBeInstanceOf(WraithContractError); + expect(error).toBeInstanceOf(NameNotFoundError); + expect(error.code).toBe('WRAITH/CONTRACT/NAME_NOT_FOUND'); + expect(error.name).toBe('NameNotFoundError'); + expect(error.context).toEqual({ name: 'missing.wraith' }); + }); + + test('NameAlreadyRegisteredError instanceof checks', () => { + const error = new NameAlreadyRegisteredError('taken.wraith', 'owner_address'); + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(WraithError); + expect(error).toBeInstanceOf(WraithContractError); + expect(error).toBeInstanceOf(NameAlreadyRegisteredError); + expect(error.code).toBe('WRAITH/CONTRACT/NAME_ALREADY_REGISTERED'); + expect(error.name).toBe('NameAlreadyRegisteredError'); + expect(error.context).toEqual({ name: 'taken.wraith', owner: 'owner_address' }); + }); + + test('InsufficientAuthError instanceof checks', () => { + const error = new InsufficientAuthError('admin', 'user'); + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(WraithError); + expect(error).toBeInstanceOf(WraithContractError); + expect(error).toBeInstanceOf(InsufficientAuthError); + expect(error.code).toBe('WRAITH/CONTRACT/INSUFFICIENT_AUTH'); + expect(error.name).toBe('InsufficientAuthError'); + expect(error.context).toEqual({ required: 'admin', actual: 'user' }); + }); + + test('ContractRevertError instanceof checks', () => { + const error = new ContractRevertError('execution reverted: out of gas', '0x111'); + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(WraithError); + expect(error).toBeInstanceOf(WraithContractError); + expect(error).toBeInstanceOf(ContractRevertError); + expect(error.code).toBe('WRAITH/CONTRACT/CONTRACT_REVERT'); + expect(error.name).toBe('ContractRevertError'); + expect(error.reason).toBe('execution reverted: out of gas'); + expect(error.context).toEqual({ reason: 'execution reverted: out of gas', txHash: '0x111' }); + }); + + // Test 5: Validate instanceof chain for WraithBuilderError and its subclasses + test('InsufficientBalanceError instanceof checks', () => { + const error = new InsufficientBalanceError(100n, 50n, 'XLM'); + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(WraithError); + expect(error).toBeInstanceOf(WraithBuilderError); + expect(error).toBeInstanceOf(InsufficientBalanceError); + expect(error.code).toBe('WRAITH/BUILDER/INSUFFICIENT_BALANCE'); + expect(error.name).toBe('InsufficientBalanceError'); + expect(error.context).toEqual({ required: '100', actual: '50', asset: 'XLM' }); + }); + + test('UnsupportedAssetError instanceof checks', () => { + const error = new UnsupportedAssetError('SOL', 'horizen'); + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(WraithError); + expect(error).toBeInstanceOf(WraithBuilderError); + expect(error).toBeInstanceOf(UnsupportedAssetError); + expect(error.code).toBe('WRAITH/BUILDER/UNSUPPORTED_ASSET'); + expect(error.name).toBe('UnsupportedAssetError'); + expect(error.context).toEqual({ asset: 'SOL', chain: 'horizen' }); + }); + + // Test 6: Validate JSON serialization and code preservation + test('JSON serialization preserves code, context, name, and docsLink', () => { + const error = new InsufficientBalanceError(500n, 100n, 'ETH'); + const jsonStr = JSON.stringify(error); + const parsed = JSON.parse(jsonStr); + + expect(parsed.name).toBe('InsufficientBalanceError'); + expect(parsed.code).toBe('WRAITH/BUILDER/INSUFFICIENT_BALANCE'); + expect(parsed.docsLink).toBe('https://docs.wraith.dev/sdk/errors#insufficient-balance'); + expect(parsed.message).toContain('Insufficient balance to build transaction for ETH'); + expect(parsed.message).toContain(parsed.docsLink); + expect(parsed.context).toEqual({ required: '500', actual: '100', asset: 'ETH' }); + }); +});