diff --git a/README.md b/README.md index 7c4fbb24..29f63c02 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ sequenceDiagram - **API Key Management** – Hashed keys stored in SQLite with balance tracking and optional expiry/refund address - **Model-Based Pricing** – Convert USD prices in `models.json` to sats using live BTC/USD rates - **Admin Dashboard** – Simple HTML interface at `/admin/` to view balances and API keys -- **Discovery** – Fetch available providers from Nostr relays using both RIP-02 and NIP-91 protocols +- **Discovery** – Fetch available providers from Nostr relays using NIP-91 protocol - **NIP-91 Auto-Announcement** – Automatically announce this provider to Nostr relays when NSEC is provided - **Docker Support** – Provided `Dockerfile` and `compose.yml` for running with an optional Tor hidden service diff --git a/router/discovery.py b/router/discovery.py index 8f36172e..0a159ab4 100644 --- a/router/discovery.py +++ b/router/discovery.py @@ -25,13 +25,13 @@ async def query_nostr_relay_for_providers( ) -> list[dict[str, Any]]: """ Query a Nostr relay for provider announcements. - Searches for both RIP-02 (kind:31338) and NIP-91 (kind:38421) events. + Searches for NIP-91 (kind:38421) events. """ events = [] - # Build filter for both RIP-02 and NIP-91 events + # Build filter for NIP-91 events filter_obj: dict[str, Any] = { - "kinds": [31338, 38421], # Both RIP-02 and NIP-91 Provider Announcements + "kinds": [38421], # NIP-91 Provider Announcements "limit": limit, } @@ -81,7 +81,7 @@ async def query_nostr_relay_for_providers( def parse_provider_announcement(event: dict[str, Any]) -> dict[str, Any] | None: """ Parse provider announcement events. - Handles both RIP-02 (kind:31338) and NIP-91 (kind:38421) formats. + Handles NIP-91 (kind:38421) format. Returns structured provider data or None if invalid. """ try: @@ -99,34 +99,8 @@ def parse_provider_announcement(event: dict[str, Any]) -> dict[str, Any] | None: mint_url = None version = None - # Parse based on event kind - if kind == 31338: # RIP-02 format - for tag in tags: - if len(tag) >= 2: - if tag[0] == "endpoint": - endpoint_urls.append(tag[1]) - elif tag[0] == "name": - provider_name = tag[1] - elif tag[0] == "d": - d_tag = tag[1] - elif tag[0] == "description": - description = tag[1] - elif tag[0] == "contact": - contact = tag[1] - elif tag[0] == "pricing": - pricing_url = tag[1] - elif tag[0] == "model": - supported_models.append(tag[1]) - - # RIP-02 requires single endpoint - endpoint_url = endpoint_urls[0] if endpoint_urls else None - - # Validate RIP-02 required fields - if not endpoint_url or not provider_name or not d_tag: - print(f"Invalid RIP-02 announcement - missing required tags: {event['id']}") - return None - - elif kind == 38421: # NIP-91 format + # Parse NIP-91 format + if kind == 38421: # NIP-91 format for tag in tags: if len(tag) >= 2: if tag[0] == "d": @@ -248,13 +222,11 @@ async def get_providers( include_json: bool = False, pubkey: str | None = None ) -> dict[str, list[dict[str, Any]]]: """ - Discover Routstr providers using both RIP-02 and NIP-91 specifications. + Discover Routstr providers using NIP-91 specification. Searches for provider announcement events on Nostr relays: - - kind:31338 (RIP-02) - kind:38421 (NIP-91) References: - - RIP-02: https://github.com/Routstr/protocol/blob/main/RIP-02.md - NIP-91: https://github.com/nostr-protocol/nips/pull/1987 """ # Default relays for provider discovery @@ -291,7 +263,7 @@ async def get_providers( print(f"Found {len(all_events)} total unique provider announcements") - # Parse provider announcements according to RIP-02 + # Parse provider announcements according to NIP-91 providers = [] for event in all_events: parsed_provider = parse_provider_announcement(event) diff --git a/scripts/publish_provider.py b/scripts/publish_provider.py deleted file mode 100644 index 847c64ab..00000000 --- a/scripts/publish_provider.py +++ /dev/null @@ -1,268 +0,0 @@ -#!/usr/bin/env python3 -""" -Simple Python function to publish one provider listing to a nostr relay -according to the RIP-02 specification. - -Based on: https://github.com/Routstr/protocol/blob/main/RIP-02.md -Event Kind: 31338 (Routstr Provider Announcements) -""" - -import asyncio -import hashlib -import json -import time -from typing import Any - -import secp256k1 -import websockets - - -def create_provider_announcement_event( - private_key_hex: str, - provider_name: str, - endpoint_url: str, - d_tag: str, - description: str | None = None, - contact: str | None = None, - pricing_url: str | None = None, - supported_models: list[str] | None = None, -) -> dict[str, Any]: - """ - Create a RIP-02 compliant provider announcement event. - - Args: - private_key_hex: 32-byte hex private key for signing - provider_name: Human readable name for the provider - endpoint_url: Base URL for the provider's API endpoint - d_tag: Unique identifier for this provider (required for addressable events) - description: Optional description of the provider - contact: Optional contact information - pricing_url: Optional URL to pricing information - supported_models: Optional list of supported model names - - Returns: - Complete signed nostr event ready for publishing - """ - # Convert hex private key to secp256k1 PrivateKey object - private_key = secp256k1.PrivateKey(bytes.fromhex(private_key_hex)) - public_key = private_key.pubkey.serialize(compressed=True)[ - 1: - ] # Remove 0x02/0x03 prefix - - # Build required tags according to RIP-02 - tags = [ - ["d", d_tag], # Required for addressable events (kind 30000-39999) - ["endpoint", endpoint_url], - ["name", provider_name], - ] - - # Add optional tags if provided - if description: - tags.append(["description", description]) - if contact: - tags.append(["contact", contact]) - if pricing_url: - tags.append(["pricing", pricing_url]) - if supported_models: - for model in supported_models: - tags.append(["model", model]) - - # Create the event structure - created_at = int(time.time()) - event_data = [ - 0, # Reserved field - public_key.hex(), # Public key as hex - created_at, # Unix timestamp - 31338, # Kind for RIP-02 Provider Announcements - tags, # Tags array - "", # Content (empty for provider announcements) - ] - - # Serialize event data for hashing - event_json = json.dumps(event_data, separators=(",", ":"), ensure_ascii=False) - - # Calculate event ID (SHA256 hash) - event_id = hashlib.sha256(event_json.encode("utf-8")).hexdigest() - - # Sign the event ID - signature = private_key.ecdsa_sign(bytes.fromhex(event_id), raw=True) - signature_der = private_key.ecdsa_serialize(signature) - - # Create the final event - event = { - "id": event_id, - "pubkey": public_key.hex(), - "created_at": created_at, - "kind": 31338, - "tags": tags, - "content": "", - "sig": signature_der.hex(), - } - - return event - - -async def publish_provider_to_relay( - relay_url: str, event: dict[str, Any], timeout: int = 30 -) -> bool: - """ - Publish a provider announcement event to a nostr relay. - - Args: - relay_url: WebSocket URL of the nostr relay (e.g., "wss://relay.damus.io") - event: Complete signed nostr event to publish - timeout: Connection timeout in seconds - - Returns: - True if successfully published, False otherwise - """ - try: - async with websockets.connect(relay_url, timeout=timeout) as websocket: - # Send EVENT message - event_message = json.dumps(["EVENT", event]) - await websocket.send(event_message) - print(f"Published event {event['id']} to {relay_url}") - - # Wait for OK response - try: - response = await asyncio.wait_for(websocket.recv(), timeout=5) - data = json.loads(response) - - if data[0] == "OK" and data[1] == event["id"]: - if data[2]: # True means accepted - print( - f"✅ Event accepted by relay: {data[3] if len(data) > 3 else ''}" - ) - return True - else: - print( - f"❌ Event rejected by relay: {data[3] if len(data) > 3 else ''}" - ) - return False - elif data[0] == "NOTICE": - print(f"📢 Relay notice: {data[1]}") - return False - else: - print(f"🤔 Unexpected response: {data}") - return False - - except asyncio.TimeoutError: - print("⏰ No response from relay within timeout") - return False - - except Exception as e: - print(f"💥 Failed to publish to {relay_url}: {e}") - return False - - -async def publish_provider_listing( - private_key_hex: str, - provider_name: str, - endpoint_url: str, - d_tag: str, - relay_urls: list[str] | None = None, - description: str | None = None, - contact: str | None = None, - pricing_url: str | None = None, - supported_models: list[str] | None = None, -) -> dict[str, bool]: - """ - Complete function to create and publish a provider listing to nostr relays. - - Args: - private_key_hex: 32-byte hex private key for signing - provider_name: Human readable name for the provider - endpoint_url: Base URL for the provider's API endpoint - d_tag: Unique identifier for this provider - relay_urls: List of relay URLs to publish to (uses defaults if None) - description: Optional description of the provider - contact: Optional contact information - pricing_url: Optional URL to pricing information - supported_models: Optional list of supported model names - - Returns: - Dictionary mapping relay URLs to success status - """ - # Use default relays if none provided - if relay_urls is None: - relay_urls = [ - "wss://relay.nostr.band", - "wss://relay.damus.io", - "wss://relay.routstr.com", - ] - - # Create the provider announcement event - event = create_provider_announcement_event( - private_key_hex=private_key_hex, - provider_name=provider_name, - endpoint_url=endpoint_url, - d_tag=d_tag, - description=description, - contact=contact, - pricing_url=pricing_url, - supported_models=supported_models, - ) - - print(f"📝 Created provider announcement event: {event['id']}") - print(f"🔑 Public key: {event['pubkey']}") - print(f"🏷️ Provider: {provider_name}") - print(f"🌐 Endpoint: {endpoint_url}") - print() - - # Publish to all specified relays - results = {} - tasks = [] - - for relay_url in relay_urls: - task = publish_provider_to_relay(relay_url, event) - tasks.append((relay_url, task)) - - # Execute all publishing tasks concurrently - for relay_url, task in tasks: - try: - success = await task - results[relay_url] = success - except Exception as e: - print(f"💥 Failed to publish to {relay_url}: {e}") - results[relay_url] = False - - return results - - -# Example usage -async def main() -> None: - """Example of how to use the provider publishing function.""" - - # Example private key (DO NOT use this in production!) - private_key = "3185a47e3802f956ca207b46c8d6b8b5c5dbad53a5ca29816050e9b66badc33c" - - # Example provider information - provider_name = "My AI Provider" - endpoint_url = "https://api.myaiprovider.com" - d_tag = "my-ai-provider-v1" # Unique identifier - description = "High-quality AI models with competitive pricing" - contact = "admin@myaiprovider.com" - pricing_url = "https://myaiprovider.com/pricing" - supported_models = ["gpt-4o", "claude-3-sonnet", "llama-3.1-70b"] - - # Publish to relays - results = await publish_provider_listing( - private_key_hex=private_key, - provider_name=provider_name, - endpoint_url=endpoint_url, - d_tag=d_tag, - description=description, - contact=contact, - pricing_url=pricing_url, - supported_models=supported_models, - ) - - # Print results - print("\n📊 Publishing Results:") - for relay_url, success in results.items(): - status = "✅ Success" if success else "❌ Failed" - print(f" {relay_url}: {status}") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/tests/integration/test_provider_management.py b/tests/integration/test_provider_management.py index f793fbbb..fc447c0e 100644 --- a/tests/integration/test_provider_management.py +++ b/tests/integration/test_provider_management.py @@ -27,25 +27,23 @@ async def test_providers_endpoint_default_response( { "id": "event1", "pubkey": "test_pubkey1", - "kind": 31338, # RIP-02 event kind + "kind": 38421, # NIP-91 event kind "created_at": 1234567890, - "content": "", + "content": '{"name": "Provider 1", "about": "Test provider 1"}', "tags": [ ["d", "provider1"], - ["endpoint", "http://provider1.onion"], - ["name", "Provider 1"], + ["u", "http://provider1.onion"], ] }, { "id": "event2", "pubkey": "test_pubkey2", - "kind": 31338, # RIP-02 event kind + "kind": 38421, # NIP-91 event kind "created_at": 1234567891, - "content": "", + "content": '{"name": "Provider 2", "about": "Test provider 2"}', "tags": [ ["d", "provider2"], - ["endpoint", "http://provider2.onion"], - ["name", "Provider 2"], + ["u", "http://provider2.onion"], ] }, ] @@ -102,13 +100,12 @@ async def test_providers_endpoint_with_include_json( { "id": "event1", "pubkey": "test_pubkey", - "kind": 31338, # RIP-02 event kind + "kind": 38421, # NIP-91 event kind "created_at": 1234567890, - "content": "", + "content": '{"name": "Test Provider", "about": "A test provider"}', "tags": [ ["d", "test-provider"], - ["endpoint", "http://test-provider.onion"], - ["name", "Test Provider"], + ["u", "http://test-provider.onion"], ] } ] @@ -168,21 +165,18 @@ async def test_providers_data_structure_validation( ) -> None: """Test provider data structure contains expected fields""" - # Mock RIP-02 provider announcement event + # Mock NIP-91 provider announcement event mock_events: list[dict[str, Any]] = [ { "id": "event1", "pubkey": "test_pubkey", "created_at": 1234567890, - "kind": 31338, # RIP-02 event kind - "content": "Comprehensive provider announcement", + "kind": 38421, # NIP-91 event kind + "content": '{"name": "Comprehensive Provider", "about": "A comprehensive AI provider"}', "tags": [ ["d", "provider-123"], - ["endpoint", "https://api.provider.example/v1"], - ["name", "Comprehensive Provider"], - ["description", "A comprehensive AI provider"], - ["model", "gpt-3.5-turbo"], - ["model", "gpt-4"], + ["u", "https://api.provider.example/v1"], + ["models", "gpt-3.5-turbo", "gpt-4"], ] } ] @@ -218,7 +212,7 @@ async def test_providers_data_structure_validation( assert "health" in provider_data provider_info = provider_data["provider"] - # Expected fields from RIP-02 parser + # Expected fields from NIP-91 parser expected_fields = ["id", "name", "endpoint_url", "supported_models"] for field in expected_fields: assert field in provider_info @@ -267,25 +261,23 @@ async def test_providers_endpoint_offline_providers( { "id": "event1", "pubkey": "healthy_provider_pubkey", - "kind": 31338, # RIP-02 event kind + "kind": 38421, # NIP-91 event kind "created_at": 1234567890, - "content": "Healthy provider announcement", + "content": '{"name": "Healthy Provider", "about": "Healthy provider announcement"}', "tags": [ ["d", "healthy-provider"], - ["endpoint", "http://healthy-provider.onion"], - ["name", "Healthy Provider"], + ["u", "http://healthy-provider.onion"], ] }, { "id": "event2", "pubkey": "offline_provider_pubkey", - "kind": 31338, # RIP-02 event kind + "kind": 38421, # NIP-91 event kind "created_at": 1234567891, - "content": "Offline provider announcement", + "content": '{"name": "Offline Provider", "about": "Offline provider announcement"}', "tags": [ ["d", "offline-provider"], - ["endpoint", "http://offline-provider.onion"], - ["name", "Offline Provider"], + ["u", "http://offline-provider.onion"], ] }, ] @@ -342,25 +334,23 @@ async def test_providers_endpoint_duplicate_urls( { "id": "event1", "pubkey": "provider_pubkey", - "kind": 31338, # RIP-02 event kind + "kind": 38421, # NIP-91 event kind "created_at": 1234567890, - "content": "Provider announcement", + "content": '{"name": "Provider", "about": "Provider announcement"}', "tags": [ ["d", "provider-1"], - ["endpoint", "http://provider.onion"], - ["name", "Provider"], + ["u", "http://provider.onion"], ] }, { "id": "event2", "pubkey": "other_provider_pubkey", - "kind": 31338, # RIP-02 event kind + "kind": 38421, # NIP-91 event kind "created_at": 1234567892, - "content": "Different provider announcement", + "content": '{"name": "Other Provider", "about": "Different provider announcement"}', "tags": [ ["d", "other-provider"], - ["endpoint", "http://other-provider.onion"], - ["name", "Other Provider"], + ["u", "http://other-provider.onion"], ] }, ]