Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ All notable changes to the Wraith Protocol SDK will be documented in this file.
- **Stellar `findStrictReceivePath` reads and validates real Horizon responses** (issue #236): the function now reads paths from Horizon's `_embedded.records` envelope instead of expecting a bare array, and sends the query parameters Horizon accepts (`source_assets` with `destination_asset_type` / `_code` / `_issuer`; the old `destination_asset` and dummy `source_account` made every live request fail with HTTP 400).
- Every record is validated before use: amounts, asset type / code / issuer, that the source and destination assets and `destination_amount` match the request, and a path of at most 5 hops. The body is read as a stream and abandoned past 256 KiB; responses over 100 records are rejected. The cheapest valid record is returned. An invalid `destAmount` now throws before the request, and the request uses the Horizon client's default timeouts (`RPCTimeoutError`).
- New `HorizonResponseError` (`WRAITH/NETWORK/HORIZON_RESPONSE`), exported from the package root, carries the URL and the rejected field. A non-2xx status now throws `RPCRequestError` instead of a plain `Error`.
- **Typed `sdk-agent` status and payment tool-call validation** (issue #238): `WraithAgent.getStatus()` now returns a typed `Promise<AgentStatus>` instead of `Promise<any>`, and both `/status` payloads and `scan_payments` tool-call JSON payloads are validated at runtime before use.
- New `AgentResponseError` (`WRAITH/NETWORK/AGENT_RESPONSE`), exported from both `@wraith-protocol/sdk` and `@wraith-protocol/sdk-agent`, is thrown when an agent endpoint returns invalid JSON, a malformed status object, or invalid payment records in `scanPayments()`.

### Changed

Expand Down
2 changes: 2 additions & 0 deletions docs/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ WraithError (Abstract Base)
├── WraithNetworkError
│ ├── RPCRequestError
│ ├── HorizonResponseError
│ ├── AgentResponseError
│ ├── RPCRetryExhaustedError
│ ├── RPCTimeoutError
│ └── RetentionExceededError
Expand Down Expand Up @@ -83,6 +84,7 @@ Thrown when HTTP queries to Wraith APIs, Horizon/Soroban endpoints, Solana clust
| :----------------------- | :------------------------------------- | :------------------------------------------------- | :--------------------------------------------------------------------------------------------------- |
| `RPCRequestError` | `"WRAITH/NETWORK/RPC_REQUEST"` | `url`, `statusCode`, `responseText` | Thrown when an HTTP/RPC endpoint returns a non-2xx status code. |
| `HorizonResponseError` | `"WRAITH/NETWORK/HORIZON_RESPONSE"` | `url`, `field`, `reason` | Thrown when Horizon answers 2xx but the body is oversized, malformed, or does not match the request. |
| `AgentResponseError` | `"WRAITH/NETWORK/AGENT_RESPONSE"` | `url`, `field`, `reason` | Thrown when the Wraith Agent API returns malformed JSON or invalid status/payment payloads. |
| `RPCRetryExhaustedError` | `"WRAITH/NETWORK/RPC_RETRY_EXHAUSTED"` | `url`, `attempts`, `lastError` | Thrown when all query retry strategies have timed out or failed. |
| `RPCTimeoutError` | `"WRAITH/NETWORK/RPC_TIMEOUT"` | `url`, `endpoint`, `attempt`, `phase`, `timeoutMs` | Thrown when a Horizon/Soroban attempt exceeds its connect or request timeout. |
| `RetentionExceededError` | `"WRAITH/NETWORK/RETENTION_EXCEEDED"` | `limit`, `actual` | Thrown when querying historical logs beyond maximum retention boundaries. |
Expand Down
10 changes: 10 additions & 0 deletions etc/sdk.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@

```ts

// @public
export class AgentResponseError extends WraithNetworkError {
constructor(url: string, field: string, reason: string);
// (undocumented)
readonly code = "WRAITH/NETWORK/AGENT_RESPONSE";
// (undocumented)
describe(): string;
readonly field: string;
}

// @public
export function assertWalletNetwork(adapter: WalletAdapter, expectedNetwork: string): Promise<void>;

Expand Down
5 changes: 5 additions & 0 deletions packages/sdk-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,11 @@ interface Balance {
tokens: Record<string, string>;
}

interface AgentStatus {
balance: string;
tokens: Record<string, string>;
}

interface Payment {
stealthAddress: string;
balance: string;
Expand Down
224 changes: 207 additions & 17 deletions packages/sdk-agent/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,187 @@
import { RPCRequestError } from '@wraith-protocol/sdk';
import { AgentResponseError, RPCRequestError } from '@wraith-protocol/sdk';
import type {
WraithConfig,
AgentConfig,
AgentInfo,
AgentStatus,
ChatResponse,
ToolCall,
Balance,
Payment,
Notification,
Conversation,
} from './types';
import { Chain } from './types';

export { AgentResponseError, RPCRequestError };

const DEFAULT_BASE_URL = 'https://api.wraith.dev';

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}

/**
* Validates a raw `/agent/:id/status` response payload at runtime.
*
* @param payload Parsed JSON value from the server.
* @param url Endpoint URL for error diagnostics.
* @returns Validated {@link AgentStatus}.
* @throws {AgentResponseError} If the payload is not an object or has invalid `balance` or `tokens`.
*/
export function validateAgentStatus(payload: unknown, url: string): AgentStatus {
if (!isRecord(payload)) {
throw new AgentResponseError(url, 'body', 'expected a JSON object');
}

if (!isNonEmptyString(payload.balance)) {
throw new AgentResponseError(url, 'balance', 'must be a non-empty string');
}

if (!isRecord(payload.tokens)) {
throw new AgentResponseError(
url,
'tokens',
'must be an object mapping token symbols to balance strings',
);
}

const tokens: Record<string, string> = {};
for (const [symbol, amount] of Object.entries(payload.tokens)) {
if (!isNonEmptyString(symbol)) {
throw new AgentResponseError(url, 'tokens', 'token symbol must be a non-empty string');
}
if (!isNonEmptyString(amount)) {
throw new AgentResponseError(url, `tokens.${symbol}`, 'must be a non-empty string');
}
tokens[symbol] = amount;
}

return {
balance: payload.balance,
tokens,
};
}

/**
* Validates a single stealth payment record at runtime.
*
* @param payload Candidate payment record.
* @param url Endpoint URL for error diagnostics.
* @param field Field path within the response.
* @returns Validated {@link Payment}.
* @throws {AgentResponseError} If any required field is missing or not a non-empty string.
*/
export function validatePayment(payload: unknown, url: string, field = 'payment'): Payment {
if (!isRecord(payload)) {
throw new AgentResponseError(url, field, 'payment record must be an object');
}

if (!isNonEmptyString(payload.stealthAddress)) {
throw new AgentResponseError(url, `${field}.stealthAddress`, 'must be a non-empty string');
}

if (!isNonEmptyString(payload.balance)) {
throw new AgentResponseError(url, `${field}.balance`, 'must be a non-empty string');
}

if (!isNonEmptyString(payload.ephemeralPubKey)) {
throw new AgentResponseError(url, `${field}.ephemeralPubKey`, 'must be a non-empty string');
}

return {
stealthAddress: payload.stealthAddress,
balance: payload.balance,
ephemeralPubKey: payload.ephemeralPubKey,
};
}

/**
* Parses and validates a `scan_payments` tool-call `detail` JSON payload into {@link Payment} records.
*
* @param detail Raw `detail` field from a `scan_payments` tool call.
* @param url Endpoint URL for error diagnostics.
* @param field Field path for the tool call's `detail` property.
* @returns Validated array of {@link Payment} records.
* @throws {AgentResponseError} If `detail` is missing, invalid JSON, not an array, or contains invalid records.
*/
export function parseToolCallPayments(detail: unknown, url: string, field = 'detail'): Payment[] {
if (!isNonEmptyString(detail)) {
throw new AgentResponseError(url, field, 'must be a non-empty JSON string');
}

let parsed: unknown;
try {
parsed = JSON.parse(detail);
} catch {
throw new AgentResponseError(url, field, 'not valid JSON');
}

if (!Array.isArray(parsed)) {
throw new AgentResponseError(url, field, 'expected a JSON array of payment records');
}

return parsed.map((entry, index) => validatePayment(entry, url, `${field}[${index}]`));
}

function validateToolCall(payload: unknown, url: string, field: string): ToolCall {
if (!isRecord(payload)) {
throw new AgentResponseError(url, field, 'tool call must be an object');
}

if (!isNonEmptyString(payload.name)) {
throw new AgentResponseError(url, `${field}.name`, 'must be a non-empty string');
}

if (!isNonEmptyString(payload.status)) {
throw new AgentResponseError(url, `${field}.status`, 'must be a non-empty string');
}

if (payload.detail !== undefined && typeof payload.detail !== 'string') {
throw new AgentResponseError(url, `${field}.detail`, 'must be a string when present');
}

return {
name: payload.name,
status: payload.status,
...(payload.detail !== undefined ? { detail: payload.detail } : {}),
};
}

function validateChatResponse(payload: unknown, url: string): ChatResponse {
if (!isRecord(payload)) {
throw new AgentResponseError(url, 'body', 'expected a JSON object');
}

if (typeof payload.response !== 'string') {
throw new AgentResponseError(url, 'response', 'must be a string');
}

if (!isNonEmptyString(payload.conversationId)) {
throw new AgentResponseError(url, 'conversationId', 'must be a non-empty string');
}

let toolCalls: ToolCall[] | undefined;
if (payload.toolCalls !== undefined) {
if (!Array.isArray(payload.toolCalls)) {
throw new AgentResponseError(url, 'toolCalls', 'must be an array');
}
toolCalls = payload.toolCalls.map((tc, index) =>
validateToolCall(tc, url, `toolCalls[${index}]`),
);
}

return {
response: payload.response,
...(toolCalls !== undefined ? { toolCalls } : {}),
conversationId: payload.conversationId,
};
}

export class Wraith {
private readonly apiKey: string;
private readonly baseUrl: string;
Expand All @@ -39,11 +208,19 @@ export class Wraith {
});

if (!res.ok) {
const error = await res.json().catch(() => ({ message: res.statusText }));
throw new RPCRequestError(url, res.status, error.message || res.statusText);
const error = (await res.json().catch(() => null)) as unknown;
const message =
isRecord(error) && typeof error.message === 'string' && error.message.length > 0
? error.message
: res.statusText;
throw new RPCRequestError(url, res.status, message);
}

return res.json();
try {
return (await res.json()) as T;
} catch {
throw new AgentResponseError(url, 'body', 'not valid JSON');
}
}

/**
Expand Down Expand Up @@ -106,6 +283,11 @@ export class Wraith {
_request<T>(method: string, path: string, body?: unknown): Promise<T> {
return this.request<T>(method, path, body);
}

/** @internal Returns the normalized base URL for error reporting. */
_getBaseUrl(): string {
return this.baseUrl;
}
}

export class WraithAgent {
Expand All @@ -121,30 +303,38 @@ export class WraithAgent {
return this.wraith._request<T>(method, `/agent/${this.info.id}${path}`, body);
}

private endpointUrl(path: string): string {
return `${this.wraith._getBaseUrl()}/agent/${this.info.id}${path}`;
}

async chat(message: string, conversationId?: string): Promise<ChatResponse> {
return this.req<ChatResponse>('POST', '/chat', { message, conversationId });
const path = '/chat';
const data = await this.req<unknown>('POST', path, { message, conversationId });
return validateChatResponse(data, this.endpointUrl(path));
}

async getStatus(): Promise<any> {
return this.req('GET', '/status');
async getStatus(): Promise<AgentStatus> {
const path = '/status';
const data = await this.req<unknown>('GET', path);
return validateAgentStatus(data, this.endpointUrl(path));
}

async getBalance(): Promise<Balance> {
const status = await this.getStatus();
return { native: status.balance || '0', tokens: status.tokens || {} };
return { native: status.balance, tokens: status.tokens };
}

async scanPayments(): Promise<Payment[]> {
const url = this.endpointUrl('/chat');
const res = await this.chat('Scan for incoming stealth payments');
return (res.toolCalls || [])
.filter((tc) => tc.name === 'scan_payments')
.flatMap((tc) => {
try {
return JSON.parse(tc.detail || '[]');
} catch {
return [];
}
});
const payments: Payment[] = [];

for (const [index, tc] of (res.toolCalls ?? []).entries()) {
if (tc.name !== 'scan_payments') continue;
payments.push(...parseToolCallPayments(tc.detail, url, `toolCalls[${index}].detail`));
}

return payments;
}

async exportKey(signature: string, message: string): Promise<{ secret: string }> {
Expand Down
11 changes: 10 additions & 1 deletion packages/sdk-agent/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
export { Wraith, WraithAgent } from './client';
export {
Wraith,
WraithAgent,
AgentResponseError,
RPCRequestError,
validateAgentStatus,
validatePayment,
parseToolCallPayments,
} from './client';
export { Chain } from './types';
export type {
WraithConfig,
AgentConfig,
AgentInfo,
AgentStatus,
ChatResponse,
ToolCall,
Balance,
Expand Down
5 changes: 5 additions & 0 deletions packages/sdk-agent/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ export interface Balance {
tokens: Record<string, string>;
}

export interface AgentStatus {
balance: string;
tokens: Record<string, string>;
}

export interface Payment {
stealthAddress: string;
balance: string;
Expand Down
Loading