@crosstown/client
v0.1.1
Published
High-level TypeScript client for publishing Nostr events to the Crosstown protocol — an ILP-gated Nostr relay that enables sustainable relay operation through micropayments.
Readme
@crosstown/client
High-level TypeScript client for publishing Nostr events to the Crosstown protocol — an ILP-gated Nostr relay that enables sustainable relay operation through micropayments.
What It Does
This client handles:
- ILP Micropayments: Pay to publish Nostr events (read is free)
- Network Bootstrap: Automatically discover and handshake with ILP peers via NIP-02 follow lists
- HTTP-Only Mode: Connects to external ILP connector service (embedded mode not yet implemented)
- TOON Encoding: Native binary format for agent-friendly event encoding
Installation
pnpm add @crosstown/client @crosstown/core @crosstown/relay nostr-toolsPrerequisites
Required Infrastructure (HTTP Mode)
The client requires external services. Use docker-compose for local development:
# Start all required services
docker compose -f docker-compose-simple.yml up -d
# Verify services are healthy
curl http://localhost:8080/health # ILP Connector (runtime)
curl http://localhost:8081/health # ILP Connector (admin)
curl http://localhost:3100/health # Crosstown BLS
# Nostr relay on ws://localhost:7100 (WebSocket, no HTTP endpoint)
# Stop infrastructure
docker compose -f docker-compose-simple.yml down| Service | Port | Purpose | |---------|------|---------| | ILP Connector (Runtime) | 8080 | Routes ILP packets to relay | | ILP Connector (Admin) | 8081 | Manages peer configuration | | Crosstown BLS | 3100 | Validates events, calculates pricing, stores events | | Nostr Relay | 7100 | WebSocket relay for peer discovery (kind:10032) |
See docker-compose-simple.yml for configuration details.
Quick Start
import { CrosstownClient } from '@crosstown/client';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { encodeEventToToon, decodeEventFromToon } from '@crosstown/relay';
// 1. Generate identity
const secretKey = generateSecretKey();
const pubkey = getPublicKey(secretKey);
// 2. Create client
const client = new CrosstownClient({
connectorUrl: 'http://localhost:8080', // Required: ILP connector endpoint
secretKey, // Required: Nostr private key
ilpInfo: { // Required: ILP peer info
pubkey,
ilpAddress: `g.crosstown.${pubkey.slice(0, 8)}`,
btpEndpoint: 'ws://localhost:3000',
},
toonEncoder: encodeEventToToon, // Required: TOON encoder
toonDecoder: decodeEventFromToon, // Required: TOON decoder
relayUrl: 'ws://localhost:7100', // Optional: defaults to ws://localhost:7100
});
// 3. Start (bootstrap network, discover peers)
const result = await client.start();
console.log(`Discovered ${result.peersDiscovered} peers`);
// 4. Publish event to relay via ILP payment
const event = finalizeEvent({
kind: 1,
content: 'Hello from Crosstown!',
tags: [],
created_at: Math.floor(Date.now() / 1000),
}, secretKey);
const publishResult = await client.publishEvent(event);
if (publishResult.success) {
console.log(`Published: ${publishResult.eventId}`);
console.log(`Fulfillment: ${publishResult.fulfillment}`);
} else {
console.error(`Failed: ${publishResult.error}`);
}
// 5. Clean up
await client.stop();API Reference
Main Class: CrosstownClient
The primary interface for interacting with the Crosstown network.
import { CrosstownClient } from '@crosstown/client';Constructor
new CrosstownClient(config: CrosstownClientConfig)Creates a new client instance. Does NOT start the client — call start() to initialize.
Throws:
ValidationError- If configuration is invalid
Configuration: CrosstownClientConfig
interface CrosstownClientConfig {
// ===== REQUIRED =====
/** HTTP URL of external ILP connector service */
connectorUrl: string; // Example: 'http://localhost:8080'
/** 32-byte Nostr private key (generated via nostr-tools) */
secretKey: Uint8Array;
/** ILP peer information for this client */
ilpInfo: {
pubkey: string; // Nostr public key (hex)
ilpAddress: string; // ILP address (e.g., 'g.crosstown.abc123')
btpEndpoint: string; // BTP WebSocket endpoint (e.g., 'ws://localhost:3000')
};
/** Function to encode Nostr events to TOON binary format */
toonEncoder: (event: NostrEvent) => Uint8Array;
/** Function to decode TOON binary to Nostr events */
toonDecoder: (bytes: Uint8Array) => NostrEvent;
// ===== OPTIONAL =====
/** Nostr relay URL for peer discovery (default: 'ws://localhost:7100') */
relayUrl?: string;
/** Query timeout in milliseconds (default: 30000) */
queryTimeout?: number;
/** Max retry attempts for failed operations (default: 3) */
maxRetries?: number;
/** Delay between retries in milliseconds (default: 1000) */
retryDelay?: number;
}Important Notes:
connectorparameter is not supported (embedded mode not implemented)- Passing
connectorwill throwValidationError: "Embedded mode not yet implemented" - HTTP mode is the only supported mode in this version
Methods
start(): Promise<CrosstownStartResult>
Starts the client, bootstraps the network, and begins monitoring for new peers.
What it does:
- Initializes HTTP runtime and admin clients
- Discovers peers via NIP-02 follow lists and kind:10032 events
- Performs SPSP handshakes with discovered peers
- Starts monitoring relay for new kind:10032 events
Returns:
{
mode: 'http', // Always 'http' in this version
peersDiscovered: number // Number of peers found during bootstrap
}Throws:
CrosstownClientError- If client is already startedCrosstownClientError- If initialization fails (wraps underlying error)
Example:
const result = await client.start();
console.log(`Mode: ${result.mode}, Peers: ${result.peersDiscovered}`);publishEvent(event: NostrEvent): Promise<PublishEventResult>
Publishes a signed Nostr event to the relay via ILP micropayment.
Parameters:
event- Must be finalized (signed withid,pubkey,sig). UsefinalizeEvent()from nostr-tools.
Returns:
{
success: boolean, // Whether event was successfully published
eventId?: string, // Event ID (if success)
fulfillment?: string, // ILP fulfillment proof (if success)
error?: string // Error message (if failure)
}Throws:
CrosstownClientError- If client is not startedCrosstownClientError- If publishing fails (network/connector error)
Example:
const event = finalizeEvent({ kind: 1, content: 'Hello', tags: [], created_at: now }, secretKey);
const result = await client.publishEvent(event);
if (result.success) {
console.log(`Published: ${result.eventId}`);
} else {
console.error(`Failed: ${result.error}`);
}stop(): Promise<void>
Stops the client and cleans up resources.
What it does:
- Stops relay monitoring subscription
- Closes SimplePool connections
- Clears internal state
Throws:
CrosstownClientError- If client is not startedCrosstownClientError- If stopping fails
Example:
await client.stop();isStarted(): boolean
Returns true if the client is currently started, false otherwise.
Example:
if (!client.isStarted()) {
await client.start();
}getPeersCount(): number
Returns the number of peers discovered during bootstrap.
Throws:
CrosstownClientError- If client is not started
Example:
const count = client.getPeersCount();
console.log(`Connected to ${count} peers`);getDiscoveredPeers(): DiscoveredPeer[]
Returns the list of peers discovered by the relay monitor.
Returns:
Array<{
ilpAddress: string;
btpEndpoint: string;
pubkey: string;
// ... other peer metadata
}>Throws:
CrosstownClientError- If client is not started
Example:
const peers = client.getDiscoveredPeers();
peers.forEach(peer => {
console.log(`Peer: ${peer.pubkey} at ${peer.ilpAddress}`);
});Error Handling
The client provides specialized error classes for different failure scenarios.
Error Class Hierarchy
CrosstownClientError (base class)
├── NetworkError // Connection failures (ECONNREFUSED, ETIMEDOUT)
├── ConnectorError // Connector server errors (5xx)
├── ValidationError // Invalid config or input
├── UnauthorizedError // Admin API 401 responses
├── PeerNotFoundError // Admin API 404 responses (peer not found)
└── PeerAlreadyExistsError // Admin API 409 responses (duplicate peer)Importing Error Classes
import {
CrosstownClientError,
NetworkError,
ConnectorError,
ValidationError,
UnauthorizedError,
PeerNotFoundError,
PeerAlreadyExistsError,
} from '@crosstown/client';Error Properties
All error classes extend CrosstownClientError with these properties:
class CrosstownClientError extends Error {
name: string; // Error class name
message: string; // Human-readable error message
code: string; // Machine-readable error code
cause?: Error; // Original error (if wrapped)
}Usage Example
try {
await client.start();
} catch (error) {
if (error instanceof NetworkError) {
// Connection to connector failed (ECONNREFUSED, timeout, DNS failure)
console.error('Cannot reach connector:', error.message);
// Retry with exponential backoff or switch to backup connector
} else if (error instanceof ConnectorError) {
// Connector returned 5xx server error
console.error('Connector is malfunctioning:', error.message);
// Alert ops team, wait before retry
} else if (error instanceof ValidationError) {
// Invalid configuration (fix before retry)
console.error('Invalid config:', error.message);
// Fix config and restart
} else if (error instanceof UnauthorizedError) {
// Admin API authentication failed
console.error('Auth failed:', error.message);
// Check auth credentials
} else if (error instanceof PeerNotFoundError) {
// Tried to remove non-existent peer
console.error('Peer not found:', error.message);
} else if (error instanceof PeerAlreadyExistsError) {
// Tried to add duplicate peer
console.error('Peer already exists:', error.message);
} else {
// Unexpected error
console.error('Unexpected error:', error);
}
}Error Codes
| Error Class | Code | Meaning |
|-------------|------|---------|
| CrosstownClientError | INVALID_STATE | Operation called in wrong state (e.g., stop() before start()) |
| CrosstownClientError | INITIALIZATION_ERROR | Client failed to initialize during start() |
| CrosstownClientError | PUBLISH_ERROR | Event publishing failed |
| CrosstownClientError | STOP_ERROR | Error during cleanup in stop() |
| NetworkError | NETWORK_ERROR | Connection failure (ECONNREFUSED, ETIMEDOUT, DNS) |
| ConnectorError | CONNECTOR_ERROR | Connector 5xx server error |
| ValidationError | VALIDATION_ERROR | Invalid configuration or input parameters |
| UnauthorizedError | UNAUTHORIZED | Admin API 401 authentication failure |
| PeerNotFoundError | PEER_NOT_FOUND | Admin API 404 peer not found |
| PeerAlreadyExistsError | PEER_ALREADY_EXISTS | Admin API 409 duplicate peer |
Advanced Usage: HTTP Adapters
For advanced use cases, you can use the HTTP adapter classes directly without CrosstownClient.
HttpRuntimeClient
Low-level client for sending ILP packets to the connector runtime API.
import { HttpRuntimeClient } from '@crosstown/client';
const runtimeClient = new HttpRuntimeClient({
connectorUrl: 'http://localhost:8080',
timeout: 30000, // Optional: request timeout (ms)
maxRetries: 3, // Optional: max retry attempts
retryDelay: 1000, // Optional: retry delay (ms)
});
const result = await runtimeClient.sendIlpPacket({
destination: 'g.crosstown.relay',
amount: '1000',
data: 'base64EncodedToonData==',
});
if (result.accepted) {
console.log('Payment accepted:', result.fulfillment);
} else {
console.error('Payment rejected:', result.code, result.message);
}Methods:
sendIlpPacket(params): Promise<IlpSendResult>params.destination- ILP address (must start withg.)params.amount- Amount in base units (stringified integer)params.data- Base64-encoded packet dataparams.timeout- Optional timeout override (ms)
Throws:
ValidationError- Invalid parameters (empty destination, malformed ILP address, invalid amount, non-Base64 data)NetworkError- Connection failure (retries automatically)ConnectorError- Connector 5xx error (no retry)
HttpConnectorAdmin
Low-level client for managing ILP peers via the connector admin API.
import { HttpConnectorAdmin } from '@crosstown/client';
const adminClient = new HttpConnectorAdmin({
adminUrl: 'http://localhost:8081',
timeout: 30000, // Optional: request timeout (ms)
maxRetries: 3, // Optional: max retry attempts
retryDelay: 1000, // Optional: retry delay (ms)
});
// Add single peer
await adminClient.addPeer({
id: 'nostr-abc123',
url: 'btp+ws://alice.example.com:3000',
authToken: 'secret-token',
routes: [{ prefix: 'g.crosstown.alice' }],
settlement: {
preference: 'payment-channel',
evmAddress: '0x...',
tokenAddress: '0x...',
tokenNetworkAddress: '0x...',
chainId: 1,
},
});
// Remove single peer
await adminClient.removePeer('nostr-abc123');
// Bulk operations (parallel execution with Promise.allSettled)
const addResults = await adminClient.addPeers([
{ id: 'peer1', url: 'btp+ws://...', authToken: 'token1' },
{ id: 'peer2', url: 'btp+ws://...', authToken: 'token2' },
]);
const removeResults = await adminClient.removePeers(['peer1', 'peer2']);
// Check results
addResults.forEach(result => {
if (result.success) {
console.log(`Added: ${result.peerId}`);
} else {
console.error(`Failed: ${result.peerId}`, result.error);
}
});Methods:
addPeer(config): Promise<void>config.id- Unique peer identifier (non-empty string)config.url- BTP WebSocket URL (must start withbtp+ws://orbtp+wss://)config.authToken- Authentication token (can be empty string for no auth)config.routes- Optional routing table entriesconfig.settlement- Optional settlement configuration
removePeer(peerId): Promise<void>peerId- Peer identifier to remove
addPeers(configs): Promise<PeerOperationResult[]>- Bulk add with parallel execution
- Returns array of results (success/error per peer)
removePeers(peerIds): Promise<PeerOperationResult[]>- Bulk remove with parallel execution
- Returns array of results (success/error per peer)
Throws:
ValidationError- Invalid parameters (empty id, malformed URL, etc.)PeerAlreadyExistsError- Peer with same ID exists (409)PeerNotFoundError- Peer doesn't exist (404)UnauthorizedError- Authentication failed (401)NetworkError- Connection failure (retries automatically)ConnectorError- Server error (5xx)
Bulk Operation Result:
interface PeerOperationResult {
peerId: string; // Peer ID that was operated on
success: boolean; // Whether operation succeeded
error?: Error; // Error object (if failed)
}Utilities
withRetry()
Retry helper with exponential backoff.
import { withRetry } from '@crosstown/client';
const result = await withRetry(
async () => {
// Your async operation
return await fetchData();
},
{
maxRetries: 3,
retryDelay: 1000,
exponentialBackoff: true,
shouldRetry: (error) => error instanceof NetworkError,
}
);Options:
maxRetries- Maximum retry attempts (default: 3)retryDelay- Initial delay between retries in ms (default: 1000)exponentialBackoff- Double delay after each retry (default: false)shouldRetry- Function to determine if error is retryable (default: retry all)
Testing
Unit & Integration Tests
cd packages/client
pnpm test # Run all unit/integration tests
pnpm test:coverage # Run with coverage reportE2E Tests
E2E tests require docker-compose infrastructure:
# Start infrastructure
docker compose -f docker-compose-simple.yml up -d
# Wait for services to start (5-10 seconds)
sleep 10
# Run E2E tests
cd packages/client
pnpm test:e2e
# Stop infrastructure
docker compose -f docker-compose-simple.yml downSee tests/e2e/README.md for detailed E2E setup.
Current Limitations
1. HTTP Mode Only
Embedded mode is not implemented. Attempting to use it will throw an error:
// ❌ NOT SUPPORTED
const client = new CrosstownClient({
connector: embeddedConnectorInstance, // ValidationError: "Embedded mode not yet implemented"
// ...
});
// ✅ SUPPORTED
const client = new CrosstownClient({
connectorUrl: 'http://localhost:8080',
// ...
});2. No Direct Payment Channels
HTTP mode does not support direct payment channel client (returns null during initialization). Payment channel management must be handled externally via the connector.
3. No Authentication
HTTP connector API is assumed to be local/trusted. Production authentication will be added in a future release.
4. Fixed Pricing
Event pricing is currently hardcoded (amount: '1000' in publishEvent()). Dynamic pricing based on event size/kind will be added in a future release.
Troubleshooting
Client Fails to Start
Symptom: CrosstownClientError: Failed to start client
Solutions:
- Verify connector is running:
curl http://localhost:8080/health - Check connector logs:
docker compose -f docker-compose-simple.yml logs connector - Verify config has valid
connectorUrl,secretKey, andilpInfo
Event Publishing Fails
Symptom: PublishEventResult.success === false
Solutions:
- Verify client is started:
if (!client.isStarted()) { await client.start(); } - Check event is properly signed (use
finalizeEventfrom nostr-tools) - Verify relay is accessible:
wscat -c ws://localhost:7100 - Check BLS logs:
docker compose -f docker-compose-simple.yml logs crosstown-node
Port Conflicts
Symptom: Error: bind: address already in use
Solutions:
# Kill processes using ports
lsof -ti:8080 | xargs kill -9 # Connector runtime
lsof -ti:8081 | xargs kill -9 # Connector admin
lsof -ti:7100 | xargs kill -9 # Nostr relay
lsof -ti:3100 | xargs kill -9 # BLS
# Restart infrastructure
docker compose -f docker-compose-simple.yml up -dNetwork Errors
Symptom: NetworkError: Failed to connect to connector
Solutions:
- Check connector is running and accessible
- Verify firewall/network settings allow connections to connector ports
- Increase timeout in config:
const client = new CrosstownClient({ // ... queryTimeout: 60000, // 60 seconds });
Examples
See packages/examples/ for more examples:
- Basic HTTP mode client
- Multi-client event publishing
- Error handling patterns
- Custom retry strategies
- Direct adapter usage
Related Packages
- @crosstown/core - Core protocol (peer discovery, SPSP, bootstrap)
- @crosstown/relay - Nostr relay with ILP payment gating
- @crosstown/bls - Business Logic Server (pricing, validation, storage)
License
MIT
Contributing
Contributions welcome! Please see CONTRIBUTING.md for guidelines.
Support
- Issues: GitHub Issues
- Discussions: GitHub Discussions
- Documentation: docs/
