diff --git a/docs/errors.md b/docs/errors.md index 76e88c5..65c931b 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -165,3 +165,31 @@ console.log(JSON.stringify(error, null, 2)); } } ``` + +--- + +## Actionable Fix Hints with `describe()` + +Every `WraithError` instance exposes a `describe(): string` method in addition to `message`, `code`, `context`, and `docsLink`. Where `message` is a compact, log-friendly summary, `describe()` returns a longer, human-readable hint — templated from the error's `context` — that suggests one or two concrete next steps, plus a link to the relevant docs anchor. This means console output and error toasts can surface useful guidance without a round-trip to the docs site. + +`WraithError` defines a generic fallback `describe()`, and every concrete subclass overrides it with a hint tailored to that specific failure mode. + +### Example + +```ts +import { InsufficientBalanceError } from '@wraith-protocol/sdk'; + +try { + // ... build a transaction +} catch (err) { + if (err instanceof InsufficientBalanceError) { + console.error(err.message); // compact summary, e.g. for log lines + console.error(err.describe()); + // "Not enough balance of XLM to build this transaction — need 100, have 50. + // Try: fund the account, reduce the amount, or account for network fees + // separately from the transfer amount. See https://docs.wraith.dev/sdk/errors#insufficient-balance." + } +} +``` + +This makes `describe()` well suited for error toasts and CLI output, where a developer (or end user) needs to know what to try next without leaving the app. diff --git a/etc/sdk.api.md b/etc/sdk.api.md index 706a09d..5b2df1a 100644 --- a/etc/sdk.api.md +++ b/etc/sdk.api.md @@ -90,6 +90,8 @@ export class ContractRevertError extends WraithContractError { // (undocumented) readonly code = "WRAITH/CONTRACT/CONTRACT_REVERT"; // (undocumented) + describe(): string; + // (undocumented) readonly reason: string; } @@ -110,6 +112,8 @@ export class ECDHFailedError extends WraithCryptoError { constructor(reason: string); // (undocumented) readonly code = "WRAITH/CRYPTO/ECDH_FAILED"; + // (undocumented) + describe(): string; } // @public (undocumented) @@ -136,6 +140,8 @@ export class InsufficientAuthError extends WraithContractError { constructor(required?: string, actual?: string); // (undocumented) readonly code = "WRAITH/CONTRACT/INSUFFICIENT_AUTH"; + // (undocumented) + describe(): string; } // @public (undocumented) @@ -143,6 +149,8 @@ export class InsufficientBalanceError extends WraithBuilderError { constructor(required: string | bigint, actual: string | bigint, asset?: string); // (undocumented) readonly code = "WRAITH/BUILDER/INSUFFICIENT_BALANCE"; + // (undocumented) + describe(): string; } // @public (undocumented) @@ -150,6 +158,8 @@ export class InvalidMetaAddressError extends WraithInputError { constructor(metaAddress: string, reason?: string); // (undocumented) readonly code = "WRAITH/INPUT/INVALID_META_ADDRESS"; + // (undocumented) + describe(): string; } // @public (undocumented) @@ -157,6 +167,8 @@ export class InvalidNameError extends WraithInputError { constructor(name: string, reason?: string); // (undocumented) readonly code = "WRAITH/INPUT/INVALID_NAME"; + // (undocumented) + describe(): string; } // @public (undocumented) @@ -164,6 +176,8 @@ export class InvalidScalarError extends WraithInputError { constructor(scalar: string | bigint, reason?: string); // (undocumented) readonly code = "WRAITH/INPUT/INVALID_SCALAR"; + // (undocumented) + describe(): string; } // @public (undocumented) @@ -171,6 +185,8 @@ export class InvalidSignatureError extends WraithInputError { constructor(signature: string | Uint8Array, expectedLength?: number, actualLength?: number); // (undocumented) readonly code = "WRAITH/INPUT/INVALID_SIGNATURE"; + // (undocumented) + describe(): string; } // @public (undocumented) @@ -200,6 +216,8 @@ export class KeyDerivationFailedError extends WraithCryptoError { constructor(reason: string); // (undocumented) readonly code = "WRAITH/CRYPTO/KEY_DERIVATION_FAILED"; + // (undocumented) + describe(): string; } // @public (undocumented) @@ -230,6 +248,8 @@ export class NameAlreadyRegisteredError extends WraithContractError { constructor(name: string, owner?: string); // (undocumented) readonly code = "WRAITH/CONTRACT/NAME_ALREADY_REGISTERED"; + // (undocumented) + describe(): string; } // @public (undocumented) @@ -237,6 +257,8 @@ export class NameNotFoundError extends WraithContractError { constructor(name: string); // (undocumented) readonly code = "WRAITH/CONTRACT/NAME_NOT_FOUND"; + // (undocumented) + describe(): string; } // @public (undocumented) @@ -285,6 +307,8 @@ export class RetentionExceededError extends WraithNetworkError { constructor(limit: number, actual: number); // (undocumented) readonly code = "WRAITH/NETWORK/RETENTION_EXCEEDED"; + // (undocumented) + describe(): string; } // @public (undocumented) @@ -293,6 +317,8 @@ export class RPCRequestError extends WraithNetworkError { // (undocumented) readonly code = "WRAITH/NETWORK/RPC_REQUEST"; // (undocumented) + describe(): string; + // (undocumented) readonly statusCode: number; } @@ -301,6 +327,8 @@ export class RPCRetryExhaustedError extends WraithNetworkError { constructor(url: string, attempts: number, lastError?: string); // (undocumented) readonly code = "WRAITH/NETWORK/RPC_RETRY_EXHAUSTED"; + // (undocumented) + describe(): string; } // @public (undocumented) @@ -390,6 +418,8 @@ export class UnsupportedAssetError extends WraithBuilderError { constructor(asset: string, chain?: string); // (undocumented) readonly code = "WRAITH/BUILDER/UNSUPPORTED_ASSET"; + // (undocumented) + describe(): string; } // @public (undocumented) @@ -397,6 +427,8 @@ export class ViewTagMismatchError extends WraithCryptoError { constructor(expectedTag: number, actualTag: number); // (undocumented) readonly code = "WRAITH/CRYPTO/VIEW_TAG_MISMATCH"; + // (undocumented) + describe(): string; } // @public (undocumented) @@ -481,6 +513,7 @@ export abstract class WraithError extends Error { abstract readonly code: string; // (undocumented) readonly context?: Record | undefined; + describe(): string; // (undocumented) readonly docsLink: string; // (undocumented) diff --git a/src/errors.ts b/src/errors.ts index bb63d72..cf4a9a6 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -34,6 +34,16 @@ export abstract class WraithError extends Error { context: this.context, }; } + + /** + * Returns a short, actionable "what to try" hint for this error, derived + * from its `context`. Concrete subclasses should override this with a hint + * tailored to their specific failure mode. The default implementation is a + * generic fallback so `describe()` is always safe to call. + */ + describe(): string { + return `No specific guidance is available for this error. See ${this.docsLink} for details.`; + } } // Intermediary Base Error Classes @@ -53,6 +63,15 @@ export class InvalidMetaAddressError extends WraithInputError { reason, }); } + + describe(): string { + const { metaAddress, reason } = this.context ?? {}; + return ( + `"${metaAddress}" is not a valid stealth meta-address${reason ? ` (${reason})` : ''}. Try: ` + + `re-generate it with encodeStealthMetaAddress() rather than building the string by hand, and ` + + `confirm it targets the network you're actually using. See ${this.docsLink}.` + ); + } } export class InvalidNameError extends WraithInputError { @@ -61,6 +80,14 @@ export class InvalidNameError extends WraithInputError { constructor(name: string, reason?: string) { super(`Invalid name: "${name}"${reason ? `. ${reason}` : ''}`, { name, reason }); } + + describe(): string { + const { name, reason } = this.context ?? {}; + return ( + `"${name}" isn't a valid .wraith name${reason ? ` (${reason})` : ''}. Try: check the length ` + + `and character rules in the docs, then re-submit. See ${this.docsLink}.` + ); + } } export class InvalidSignatureError extends WraithInputError { @@ -77,6 +104,19 @@ export class InvalidSignatureError extends WraithInputError { { signature: sigStr, expectedLength, actualLength }, ); } + + describe(): string { + const { expectedLength, actualLength } = this.context ?? {}; + const lengthHint = + expectedLength !== undefined && actualLength !== undefined + ? `Expected ${expectedLength} bytes but got ${actualLength}. ` + : ''; + return ( + `The signature is malformed. ${lengthHint}Try: confirm the signer produced a raw signature ` + + `(not hex-prefixed or base64-wrapped) and that you're passing the right byte encoding. ` + + `See ${this.docsLink}.` + ); + } } export class InvalidScalarError extends WraithInputError { @@ -88,6 +128,15 @@ export class InvalidScalarError extends WraithInputError { reason, }); } + + describe(): string { + const { reason } = this.context ?? {}; + return ( + `The computed scalar is out of valid curve range${reason ? ` (${reason})` : ''}. Try: this is ` + + `usually transient — retry the key derivation with fresh randomness, or check the inputs that ` + + `fed into it. See ${this.docsLink}.` + ); + } } // WraithCryptoError Subclasses @@ -97,6 +146,14 @@ export class KeyDerivationFailedError extends WraithCryptoError { constructor(reason: string) { super(`Key derivation failed: ${reason}`, { reason }); } + + describe(): string { + const { reason } = this.context ?? {}; + return ( + `Stealth key derivation failed (${reason}). Try: verify the signature and spending/viewing ` + + `keys used for derivation are from the same account, and retry. See ${this.docsLink}.` + ); + } } export class ViewTagMismatchError extends WraithCryptoError { @@ -108,6 +165,15 @@ export class ViewTagMismatchError extends WraithCryptoError { actualTag, }); } + + describe(): string { + const { expectedTag, actualTag } = this.context ?? {}; + return ( + `View tag ${actualTag} doesn't match the expected ${expectedTag}. Try: this announcement ` + + `likely isn't for you — it's expected and safe to skip during a scan. Only investigate if ` + + `this happens for an announcement you know is yours. See ${this.docsLink}.` + ); + } } export class ECDHFailedError extends WraithCryptoError { @@ -116,6 +182,14 @@ export class ECDHFailedError extends WraithCryptoError { constructor(reason: string) { super(`Elliptic Curve Diffie-Hellman (ECDH) operation failed: ${reason}`, { reason }); } + + describe(): string { + const { reason } = this.context ?? {}; + return ( + `ECDH failed (${reason}). Try: confirm the public point you're using is actually on the curve ` + + `and wasn't corrupted or hex-decoded incorrectly upstream. See ${this.docsLink}.` + ); + } } // WraithNetworkError Subclasses @@ -134,6 +208,19 @@ export class RPCRequestError extends WraithNetworkError { ); this.statusCode = statusCode; } + + describe(): string { + const { url, statusCode } = this.context ?? {}; + const hint = + statusCode >= 500 + ? 'the endpoint is likely having issues — retry with backoff or switch RPC providers' + : statusCode === 429 + ? 'you are being rate-limited — slow down requests or use a different endpoint' + : statusCode === 401 || statusCode === 403 + ? 'check your API key / auth header for this endpoint' + : 'check the request payload and endpoint URL for correctness'; + return `RPC call to "${url}" returned ${statusCode}. Try: ${hint}. See ${this.docsLink}.`; + } } export class RPCRetryExhaustedError extends WraithNetworkError { @@ -147,6 +234,15 @@ export class RPCRetryExhaustedError extends WraithNetworkError { { url, attempts, lastError }, ); } + + describe(): string { + const { url, attempts, lastError } = this.context ?? {}; + return ( + `Gave up on "${url}" after ${attempts} attempts${lastError ? ` (last error: ${lastError})` : ''}. ` + + `Try: check the endpoint is reachable and healthy, or configure a fallback RPC URL. ` + + `See ${this.docsLink}.` + ); + } } export class RetentionExceededError extends WraithNetworkError { @@ -158,6 +254,14 @@ export class RetentionExceededError extends WraithNetworkError { actual, }); } + + describe(): string { + const { limit, actual } = this.context ?? {}; + return ( + `Requested a range of ${actual}, but the max retention window is ${limit}. Try: narrow the ` + + `query to a smaller time/block range, or paginate across multiple requests. See ${this.docsLink}.` + ); + } } // WraithContractError Subclasses @@ -167,6 +271,14 @@ export class NameNotFoundError extends WraithContractError { constructor(name: string) { super(`Name not found: "${name}"`, { name }); } + + describe(): string { + const { name } = this.context ?? {}; + return ( + `"${name}" isn't registered in the Wraith Names registry. Try: double-check the spelling, or ` + + `confirm it has actually been registered on the network you're querying. See ${this.docsLink}.` + ); + } } export class NameAlreadyRegisteredError extends WraithContractError { @@ -178,6 +290,15 @@ export class NameAlreadyRegisteredError extends WraithContractError { owner, }); } + + describe(): string { + const { name, owner } = this.context ?? {}; + return ( + `"${name}" is already taken${owner ? ` (owned by ${owner})` : ''}. Try: pick a different name, ` + + `or if you believe you own it, verify you're signing with the correct account. ` + + `See ${this.docsLink}.` + ); + } } export class InsufficientAuthError extends WraithContractError { @@ -191,6 +312,15 @@ export class InsufficientAuthError extends WraithContractError { { required, actual }, ); } + + describe(): string { + const { required, actual } = this.context ?? {}; + const detail = required && actual ? ` Required "${required}", but got "${actual}".` : ''; + return ( + `You don't have permission to perform this operation.${detail} Try: sign with the account ` + + `that owns this resource, or request the correct role/authorization. See ${this.docsLink}.` + ); + } } export class ContractRevertError extends WraithContractError { @@ -204,6 +334,15 @@ export class ContractRevertError extends WraithContractError { }); this.reason = reason; } + + describe(): string { + const { reason, txHash } = this.context ?? {}; + return ( + `Transaction reverted on-chain: ${reason}${txHash ? ` (tx: ${txHash})` : ''}. Try: decode the ` + + `revert reason with decodeSorobanError() for a contract-specific explanation, or inspect the ` + + `transaction in an explorer. See ${this.docsLink}.` + ); + } } // WraithBuilderError Subclasses @@ -216,6 +355,15 @@ export class InsufficientBalanceError extends WraithBuilderError { { required: required.toString(), actual: actual.toString(), asset }, ); } + + describe(): string { + const { required, actual, asset } = this.context ?? {}; + return ( + `Not enough balance${asset ? ` of ${asset}` : ''} to build this transaction — need ${required}, ` + + `have ${actual}. Try: fund the account, reduce the amount, or account for network fees ` + + `separately from the transfer amount. See ${this.docsLink}.` + ); + } } export class UnsupportedAssetError extends WraithBuilderError { @@ -227,4 +375,13 @@ export class UnsupportedAssetError extends WraithBuilderError { chain, }); } + + describe(): string { + const { asset, chain } = this.context ?? {}; + return ( + `"${asset}" isn't supported${chain ? ` on ${chain}` : ''} by this SDK build. Try: check the ` + + `supported asset list for this chain, or register the asset if the SDK exposes a way to. ` + + `See ${this.docsLink}.` + ); + } } diff --git a/test/errors.test.ts b/test/errors.test.ts index 9a2da8a..1d54a69 100644 --- a/test/errors.test.ts +++ b/test/errors.test.ts @@ -232,4 +232,44 @@ describe('Wraith Custom Errors Taxonomy', () => { expect(parsed.message).toContain(parsed.docsLink); expect(parsed.context).toEqual({ required: '500', actual: '100', asset: 'ETH' }); }); + + // Test 7: describe() returns a non-empty, tailored hint for every concrete subclass + describe('describe() fix hints', () => { + const cases: Array<[string, WraithError]> = [ + ['InvalidMetaAddressError', new InvalidMetaAddressError('st:eth:0x123', 'bad length')], + ['InvalidNameError', new InvalidNameError('alice.wraith', 'too short')], + ['InvalidSignatureError', new InvalidSignatureError('0xabc', 65, 3)], + ['InvalidScalarError', new InvalidScalarError(0n, 'is zero')], + ['KeyDerivationFailedError', new KeyDerivationFailedError('invalid scalar addition')], + ['ViewTagMismatchError', new ViewTagMismatchError(42, 24)], + ['ECDHFailedError', new ECDHFailedError('point off curve')], + ['RPCRequestError', new RPCRequestError('https://horizon.stellar.org', 404, 'Not Found')], + [ + 'RPCRetryExhaustedError', + new RPCRetryExhaustedError('https://horizon.stellar.org', 5, 'timeout'), + ], + ['RetentionExceededError', new RetentionExceededError(100, 105)], + ['NameNotFoundError', new NameNotFoundError('missing.wraith')], + [ + 'NameAlreadyRegisteredError', + new NameAlreadyRegisteredError('taken.wraith', 'owner_address'), + ], + ['InsufficientAuthError', new InsufficientAuthError('admin', 'user')], + ['ContractRevertError', new ContractRevertError('execution reverted: out of gas', '0x111')], + ['InsufficientBalanceError', new InsufficientBalanceError(100n, 50n, 'XLM')], + ['UnsupportedAssetError', new UnsupportedAssetError('SOL', 'horizen')], + ]; + + test.each(cases)('%s.describe() returns a non-empty, tailored hint', (className, error) => { + const hint = error.describe(); + expect(typeof hint).toBe('string'); + expect(hint.length).toBeGreaterThan(0); + // The hint should point back to the docs and not just be the generic base fallback + expect(hint).toContain(error.docsLink); + expect(hint).not.toBe( + `No specific guidance is available for this error. See ${error.docsLink} for details.`, + ); + expect(hint).not.toBe(className); // sanity: not accidentally the class name + }); + }); });