Merge branch 'cursor/discover-and-announce-routstr-providers-3b9a' into dev

This commit is contained in:
Shroominic
2025-09-03 09:11:40 +01:00
11 changed files with 2127 additions and 1688 deletions
+4 -1
View File
@@ -33,7 +33,8 @@ 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
- **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
## Getting Started
@@ -86,6 +87,8 @@ This builds the image and also starts a Tor container exposing the API as a hidd
The most common settings are shown below. See `.env.example` for the full list.
### Core Settings
- `UPSTREAM_BASE_URL` URL of the OpenAI-compatible service
- `UPSTREAM_API_KEY` API key for the upstream service (optional)
- `MODEL_BASED_PRICING` Set to `true` to use pricing from `models.json`
+1
View File
@@ -6,6 +6,7 @@ services:
volumes:
- .:/app
- ./logs:/app/logs
- tor-data:/var/lib/tor:ro
env_file:
- .env
environment:
+11 -5
View File
@@ -5,6 +5,7 @@ Routstr Core integrates with Nostr (Notes and Other Stuff Transmitted by Relays)
## Overview
Nostr integration provides:
- **Decentralized Discovery**: Find providers through relay network
- **Cryptographic Identity**: Providers identified by public keys
- **Real-time Updates**: Live provider status and pricing
@@ -34,6 +35,7 @@ Providers announce themselves by publishing signed events to Nostr relays. Clien
### Setting Up Nostr Identity
1. **Generate Nostr Keys**
```bash
# Using nostril or similar tool
nostril --generate-keypair
@@ -44,6 +46,7 @@ Providers announce themselves by publishing signed events to Nostr relays. Clien
```
2. **Configure Environment**
```bash
# .env
NPUB=npub1xyz... # Your public key
@@ -104,7 +107,7 @@ DEFAULT_RELAYS = [
]
# Custom relay configuration
NOSTR_RELAYS=wss://relay1.com,wss://relay2.com
RELAYS=wss://relay1.com,wss://relay2.com
```
## Client Discovery
@@ -276,6 +279,7 @@ def select_provider(
### Automatic Updates
Routstr publishes updates when:
- Node starts up
- Configuration changes
- Models are added/removed
@@ -295,7 +299,7 @@ async def publish_provider_info():
pricing=get_current_pricing()
)
await publish_to_relays(event, NOSTR_RELAYS)
await publish_to_relays(event, RELAYS)
```
### Event Lifecycle
@@ -315,7 +319,7 @@ async def update_nostr_presence():
async def remove_nostr_presence():
"""Remove provider from discovery."""
deletion_event = create_deletion_event()
await publish_to_relays(deletion_event, NOSTR_RELAYS)
await publish_to_relays(deletion_event, RELAYS)
```
## Security Considerations
@@ -323,6 +327,7 @@ async def remove_nostr_presence():
### Key Management
1. **Secure Storage**
```python
# Never log private keys
SENSITIVE_VARS = ['NSEC', 'ADMIN_PASSWORD']
@@ -335,6 +340,7 @@ async def remove_nostr_presence():
```
2. **Key Rotation**
```bash
# Generate new keys
nostril --generate-keypair
@@ -524,7 +530,7 @@ async def debug_discovery():
issues = []
# Check relay connectivity
for relay in NOSTR_RELAYS:
for relay in RELAYS:
if not await can_connect_to_relay(relay):
issues.append(f"Cannot connect to {relay}")
@@ -589,4 +595,4 @@ wscat -c wss://relay.damus.io
- [Tor Support](tor.md) - Anonymous provider access
- [Custom Pricing](custom-pricing.md) - Dynamic pricing strategies
- [API Reference](../api/endpoints.md) - Discovery API details
- [API Reference](../api/endpoints.md) - Discovery API details
+1
View File
@@ -16,6 +16,7 @@ dependencies = [
"cashu",
"secp256k1",
"marshmallow>=3.13,<4.0",
"websockets>=12.0",
]
[dependency-groups]
+9 -4
View File
@@ -293,14 +293,19 @@ def setup_logging() -> None:
"handlers": ["console"] if console_enabled else [],
"propagate": False,
},
"websockets": {
"level": "WARNING",
"handlers": [],
"propagate": False,
},
"uvicorn.access": {
"level": log_level, # Use the configured log level instead of WARNING
"handlers": handlers, # Use both console and file handlers
"level": "WARNING",
"handlers": ["file"],
"propagate": False,
},
"uvicorn.error": {
"level": log_level, # Use the configured log level
"handlers": handlers, # Use both console and file handlers
"level": log_level,
"handlers": handlers,
"propagate": False,
},
"watchfiles.main": {"level": "WARNING", "handlers": [], "propagate": False},
+7
View File
@@ -10,6 +10,7 @@ from starlette.exceptions import HTTPException
from ..balance import balance_router, deprecated_wallet_router
from ..discovery import providers_router
from ..nip91 import announce_provider
from ..payment.models import MODELS, models_router, update_sats_pricing
from ..proxy import proxy_router
from ..wallet import periodic_payout
@@ -32,6 +33,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
pricing_task = None
payout_task = None
nip91_task = None
try:
# Run database migrations on startup
@@ -46,6 +48,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
pricing_task = asyncio.create_task(update_sats_pricing())
payout_task = asyncio.create_task(periodic_payout())
nip91_task = asyncio.create_task(announce_provider())
yield
@@ -62,6 +65,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
pricing_task.cancel()
if payout_task is not None:
payout_task.cancel()
if nip91_task is not None:
nip91_task.cancel()
try:
tasks_to_wait = []
@@ -69,6 +74,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
tasks_to_wait.append(pricing_task)
if payout_task is not None:
tasks_to_wait.append(payout_task)
if nip91_task is not None:
tasks_to_wait.append(nip91_task)
if tasks_to_wait:
await asyncio.gather(*tasks_to_wait, return_exceptions=True)
+83 -34
View File
@@ -28,14 +28,14 @@ async def query_nostr_relay_for_providers(
timeout: int = 30,
) -> list[dict[str, Any]]:
"""
Query a Nostr relay for provider announcements using RIP-02 spec.
Searches for kind 31338 events (Routstr Provider Announcements).
Query a Nostr relay for provider announcements.
Searches for NIP-91 (kind:38421) events.
"""
events = []
# Build filter according to RIP-02 spec
# Build filter for NIP-91 events
filter_obj: dict[str, Any] = {
"kinds": [31338], # RIP-02 Provider Announcement events
"kinds": [38421], # NIP-91 Provider Announcements
"limit": limit,
}
@@ -47,13 +47,13 @@ async def query_nostr_relay_for_providers(
req_message = json.dumps(["REQ", sub_id, filter_obj])
try:
async with websockets.connect(relay_url, timeout=timeout) as websocket:
logger.debug("Connected to relay, searching for kind 31338 events")
async with websockets.connect(relay_url, open_timeout=timeout) as websocket:
logger.debug("Connected to relay, searching for NIP-91 events (kind 38421)")
await websocket.send(req_message)
while True:
try:
message = await asyncio.wait_for(websocket.recv(), timeout=5)
message = await asyncio.wait_for(websocket.recv(), timeout=50)
data = json.loads(message)
if data[0] == "EVENT" and data[1] == sub_id:
@@ -84,17 +84,19 @@ async def query_nostr_relay_for_providers(
def parse_provider_announcement(event: dict[str, Any]) -> dict[str, Any] | None:
"""
Parse a kind 31338 provider announcement event according to RIP-02 spec.
Parse provider announcement events.
Handles NIP-91 (kind:38421) format.
Returns structured provider data or None if invalid.
"""
try:
# Extract required tags according to RIP-02
tags = event.get("tags", [])
kind = event.get("kind")
# Find required tags
endpoint_url = None
provider_name = None
# Common fields
d_tag = None
endpoint_urls = []
provider_name = None
endpoint_url = None
for tag in tags:
if len(tag) >= 2:
@@ -105,8 +107,8 @@ def parse_provider_announcement(event: dict[str, Any]) -> dict[str, Any] | None:
elif tag[0] == "d":
d_tag = tag[1]
# Validate required fields
if not endpoint_url or not provider_name or not d_tag:
# Early validation only applies to legacy/other kinds, not NIP-91
if kind != 38421 and (not endpoint_url or not provider_name or not d_tag):
logger.warning(
f"Invalid provider announcement - missing required tags: {event['id']}"
)
@@ -117,34 +119,75 @@ def parse_provider_announcement(event: dict[str, Any]) -> dict[str, Any] | None:
contact = None
pricing_url = None
supported_models = []
mint_url = None
version = None
for tag in tags:
if len(tag) >= 2:
if 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])
# Parse NIP-91 format
if kind == 38421: # NIP-91 format
for tag in tags:
if len(tag) >= 2:
if tag[0] == "d":
d_tag = tag[1]
elif tag[0] == "u":
endpoint_urls.append(tag[1])
elif tag[0] == "models" and len(tag) > 1:
# NIP-91 uses single models tag with multiple values
supported_models = tag[1:]
elif tag[0] == "mint":
mint_url = tag[1]
elif tag[0] == "version":
version = tag[1]
# Parse metadata from content for NIP-91
content = event.get("content", "")
if content:
try:
metadata = json.loads(content)
provider_name = metadata.get("name", "Unknown Provider")
description = metadata.get("about")
contact = metadata.get("contact")
except (json.JSONDecodeError, TypeError):
provider_name = "Unknown Provider"
else:
provider_name = "Unknown Provider"
# Use first URL as primary endpoint
endpoint_url = endpoint_urls[0] if endpoint_urls else None
# Validate NIP-91 required fields
if not endpoint_url or not d_tag:
logger.warning(
f"Invalid NIP-91 announcement - missing required fields: {event['id']}"
)
return None
else:
logger.warning(
f"Unknown event kind when parsing provider announcement: {kind}"
)
return None
return {
"id": event["id"],
"pubkey": event["pubkey"],
"created_at": event["created_at"],
"kind": kind,
"d_tag": d_tag,
"endpoint_url": endpoint_url,
"endpoint_urls": endpoint_urls, # All URLs for NIP-91
"name": provider_name,
"description": description,
"contact": contact,
"pricing_url": pricing_url,
"mint_url": mint_url,
"version": version,
"supported_models": supported_models,
"content": event.get("content", ""),
}
except Exception as e:
logger.error(f"Error parsing provider announcement {event.get('id', 'unknown')}: {e}")
logger.error(
f"Error parsing provider announcement {event.get('id', 'unknown')}: {e}"
)
return None
@@ -208,17 +251,23 @@ async def get_providers(
include_json: bool = False, pubkey: str | None = None
) -> dict[str, list[dict[str, Any]]]:
"""
Discover Routstr providers using RIP-02 specification.
Searches for kind 31338 provider announcement events on Nostr relays.
Discover Routstr providers using NIP-91 specification.
Searches for provider announcement events on Nostr relays:
- kind:38421 (NIP-91)
Reference: https://github.com/Routstr/protocol/blob/main/RIP-02.md
References:
- NIP-91: https://github.com/nostr-protocol/nips/pull/1987
"""
# Default relays for provider discovery
discovery_relays = [
"wss://relay.nostr.band",
"wss://relay.damus.io",
"wss://relay.routstr.com",
]
# Configure relays: use RELAYS or defaults
relays_env = os.getenv("RELAYS") or ""
discovery_relays = [r.strip() for r in relays_env.split(",") if r.strip()]
if not discovery_relays:
discovery_relays = [
"wss://relay.nostr.band",
"wss://relay.damus.io",
"wss://relay.routstr.com",
]
all_events = []
event_ids = set() # To avoid duplicates
@@ -247,7 +296,7 @@ async def get_providers(
logger.info(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)
+611
View File
@@ -0,0 +1,611 @@
#!/usr/bin/env python3
"""
NIP-91: Routstr Provider Discoverability Implementation
Automatically announces this Routstr proxy instance to Nostr relays.
"""
import asyncio
import hashlib
import json
import os
import time
from typing import Any, cast
import secp256k1
import websockets
from .core import get_logger
from .payment.models import MODELS
logger = get_logger(__name__)
def get_app_version() -> str | None:
try:
from .core.main import __version__ as imported_version
return imported_version
except Exception:
return None
def _schnorr_sign_event_id(
private_key: secp256k1.PrivateKey, event_id_hex: str
) -> bytes:
"""Return 64-byte Schnorr signature over the 32-byte event id."""
msg32 = bytes.fromhex(event_id_hex)
# Try common API variants exposed by python-secp256k1 bindings
method = getattr(private_key, "schnorr_sign", None)
if callable(method):
try:
sig = method(msg32)
if isinstance(sig, bytes) and len(sig) == 64:
return sig
except TypeError:
pass
try:
sig = method(msg32, None, True)
if isinstance(sig, bytes) and len(sig) == 64:
return sig
except TypeError:
pass
method32 = getattr(private_key, "schnorr_sign32", None)
if callable(method32):
sig = method32(msg32)
if isinstance(sig, bytes) and len(sig) == 64:
return sig
raise RuntimeError("Schnorr signing not available in secp256k1 binding")
def nsec_to_keypair(nsec: str) -> tuple[str, str] | None:
"""
Convert a Nostr private key (nsec) to a keypair (privkey_hex, pubkey_hex).
Args:
nsec: Nostr private key in nsec format or hex format
Returns:
Tuple of (private_key_hex, public_key_hex) or None if invalid
"""
try:
# Handle nsec format
if nsec.startswith("nsec"):
# Simple bech32 decode - for production use a proper library
# For now, we'll assume hex format is passed
logger.warning("nsec format not yet implemented, please use hex format")
return None
# Assume hex format
if len(nsec) != 64:
logger.error(f"Invalid private key length: {len(nsec)}")
return None
private_key = secp256k1.PrivateKey(bytes.fromhex(nsec))
pubkey_obj = cast(secp256k1.PublicKey, private_key.pubkey)
public_key = pubkey_obj.serialize(compressed=True)[
1:
] # Remove 0x02/0x03 prefix
return (nsec, public_key.hex())
except Exception as e:
logger.error(f"Failed to convert nsec to keypair: {e}")
return None
def create_nip91_event(
private_key_hex: str,
provider_id: str,
endpoint_urls: list[str],
supported_models: list[str],
mint_url: str | None = None,
version: str | None = None,
metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""
Create a NIP-91 compliant provider announcement event (kind:38421).
Args:
private_key_hex: 32-byte hex private key for signing
provider_id: Unique identifier for this provider (d tag)
endpoint_urls: List of URLs to connect to the provider
supported_models: List of supported AI model IDs
mint_url: Optional ecash mint URL for payments
version: Provider software version
metadata: Optional metadata dictionary (name, picture, about, etc.)
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))
pubkey_obj = cast(secp256k1.PublicKey, private_key.pubkey)
public_key = pubkey_obj.serialize(compressed=True)[1:] # Remove 0x02/0x03 prefix
# Build tags according to NIP-91
tags = [
["d", provider_id], # Unique identifier
]
# Add URLs
for url in endpoint_urls:
tags.append(["u", url])
# Add models as a single tag with multiple values
# if supported_models:
# tags.append(["models"] + supported_models)
# Add optional tags
if mint_url:
tags.append(["mint", mint_url])
if version:
tags.append(["version", version])
# Add model capabilities if detailed info available
# for model in MODELS:
# if model.id in supported_models:
# capabilities = []
# # Add max_tokens from context_length
# if model.context_length:
# capabilities.append(f"max_tokens:{model.context_length}")
# # Check if model supports vision (simplified check)
# if any(modal in ["image"] for modal in model.architecture.input_modalities):
# capabilities.append("vision:true")
# else:
# capabilities.append("vision:false")
# # Check if model supports tools (simplified - most modern models do)
# if "gpt" in model.id or "claude" in model.id or "llama" in model.id:
# capabilities.append("tools:true")
# else:
# capabilities.append("tools:false")
# if capabilities:
# tags.append(["model-cap", model.id, ",".join(capabilities)])
# Content is optional metadata as JSON string
content = ""
if metadata:
content = json.dumps(metadata, separators=(",", ":"))
# 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
38421, # Kind for NIP-91 Provider Announcements
tags, # Tags array
content, # Content (metadata)
]
# 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 using Schnorr (BIP-340)
sig_hex = _schnorr_sign_event_id(private_key, event_id).hex()
# Create the final event
event = {
"id": event_id,
"pubkey": public_key.hex(),
"created_at": created_at,
"kind": 38421,
"tags": tags,
"content": content,
"sig": sig_hex,
}
return event
def _get_tag_values(event: dict[str, Any], key: str) -> list[str]:
tags = event.get("tags", [])
values: list[str] = []
for tag in tags:
if isinstance(tag, list) and tag and tag[0] == key and len(tag) >= 2:
values.append(tag[1])
return values
def _get_single_tag_value(event: dict[str, Any], key: str) -> str | None:
values = _get_tag_values(event, key)
return values[0] if values else None
def _parse_content_json(content: str) -> dict[str, Any]:
if not content:
return {}
try:
parsed = json.loads(content)
return parsed if isinstance(parsed, dict) else {}
except Exception:
return {}
def events_semantically_equal(a: dict[str, Any], b: dict[str, Any]) -> bool:
if a.get("kind") != b.get("kind"):
return False
if _get_single_tag_value(a, "d") != _get_single_tag_value(b, "d"):
return False
urls_a = set(_get_tag_values(a, "u"))
urls_b = set(_get_tag_values(b, "u"))
if urls_a != urls_b:
return False
if _get_single_tag_value(a, "mint") != _get_single_tag_value(b, "mint"):
return False
if _get_single_tag_value(a, "version") != _get_single_tag_value(b, "version"):
return False
content_a = _parse_content_json(cast(str, a.get("content", "")))
content_b = _parse_content_json(cast(str, b.get("content", "")))
if content_a != content_b:
return False
return True
async def query_nip91_events(
relay_url: str,
pubkey: str,
provider_id: str | None = None,
timeout: int = 30,
) -> list[dict[str, Any]]:
"""
Query a Nostr relay for NIP-91 provider announcements (kind:38421).
Args:
relay_url: WebSocket URL of the nostr relay
pubkey: Public key to filter by
timeout: Connection timeout in seconds
Returns:
List of NIP-91 events from the given pubkey
"""
events = []
# Build filter for NIP-91 events from specific pubkey
filter_obj: dict[str, Any] = {
"kinds": [38421],
"authors": [pubkey],
"limit": 10,
}
if provider_id:
filter_obj["#d"] = [provider_id]
sub_id = f"nip91_{int(time.time())}"
req_message = json.dumps(["REQ", sub_id, filter_obj])
try:
async with websockets.connect(relay_url, open_timeout=timeout) as websocket:
logger.debug(f"Querying {relay_url} for existing NIP-91 events")
await websocket.send(req_message)
while True:
try:
message = await asyncio.wait_for(websocket.recv(), timeout=5)
data = json.loads(message)
if data[0] == "EVENT" and data[1] == sub_id:
event = data[2]
logger.debug(f"Found existing NIP-91 event: {event['id']}")
events.append(event)
elif data[0] == "EOSE" and data[1] == sub_id:
logger.debug("Received EOSE message")
break
elif data[0] == "NOTICE":
logger.warning(f"Relay notice: {data[1]}")
except asyncio.TimeoutError:
logger.debug("Timeout waiting for relay response")
break
except json.JSONDecodeError:
logger.debug("Failed to decode relay message as JSON")
continue
await websocket.send(json.dumps(["CLOSE", sub_id]))
except Exception as e:
logger.error(f"Failed to query relay {relay_url}: {e}")
return events
def discover_onion_url_from_tor(base_dir: str = "/var/lib/tor") -> str | None:
"""Discover onion URL by reading Tor hidden service hostname files.
Tries common paths first, then scans recursively for any 'hostname' file.
Returns an http URL like 'http://<host>.onion' if found.
"""
common_candidates = [
os.path.join(base_dir, "hs", "router", "hostname"),
os.path.join(base_dir, "hs", "ROUTER", "hostname"),
os.path.join(base_dir, "hidden_service", "hostname"),
]
for candidate in common_candidates:
try:
with open(candidate, "r", encoding="utf-8") as f:
host = f.readline().strip()
if host and host.endswith(".onion"):
return f"http://{host}"
except Exception:
pass
try:
for root, _dirs, files in os.walk(base_dir):
if "hostname" in files:
path = os.path.join(root, "hostname")
try:
with open(path, "r", encoding="utf-8") as f:
host = f.readline().strip()
if host and host.endswith(".onion"):
return f"http://{host}"
except Exception:
continue
except Exception:
pass
return None
async def _determine_provider_id(public_key_hex: str, relay_urls: list[str]) -> str:
explicit = os.getenv("PROVIDER_ID") or os.getenv("NIP91_PROVIDER_ID")
if explicit:
logger.info(f"Using configured provider_id from env: {explicit}")
return explicit
latest_event: dict[str, Any] | None = None
latest_ts = -1
for relay_url in relay_urls:
try:
events = await query_nip91_events(relay_url, public_key_hex, None)
for ev in events:
ts = int(ev.get("created_at", 0))
if ts > latest_ts:
latest_event = ev
latest_ts = ts
except Exception:
continue
existing_d = _get_single_tag_value(latest_event, "d") if latest_event else None
if existing_d:
logger.info(f"Reusing existing provider_id from relay: {existing_d}")
return existing_d
fallback = public_key_hex[:12]
logger.info(f"No existing provider_id found; using fallback: {fallback}")
return fallback
async def publish_to_relay(
relay_url: str,
event: dict[str, Any],
timeout: int = 30,
) -> bool:
"""
Publish a NIP-91 event to a nostr relay.
Args:
relay_url: WebSocket URL of the nostr relay
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, open_timeout=timeout) as websocket:
# Send EVENT message
event_message = json.dumps(["EVENT", event])
await websocket.send(event_message)
logger.debug(f"Sent NIP-91 event {event['id']} to {relay_url}")
# Wait for OK response
try:
response = await asyncio.wait_for(websocket.recv(), timeout=50)
data = json.loads(response)
logger.debug(f"Relay response: {data}")
if data[0] == "OK" and data[1] == event["id"]:
if data[2]: # True means accepted
logger.info(f"Event accepted by {relay_url}")
return True
else:
logger.warning(f"Event rejected by {relay_url}")
return False
elif data[0] == "NOTICE":
logger.warning(f"Relay notice from {relay_url}: {data[1]}")
return False
else:
logger.debug(f"Unexpected response from {relay_url}: {data}")
return False
except asyncio.TimeoutError:
logger.warning(f"No response from {relay_url} within timeout")
return False
except Exception as e:
logger.error(f"Failed to publish to {relay_url}: {e}")
return False
async def announce_provider() -> None:
"""
Background task to announce this Routstr provider to Nostr relays.
Checks for existing announcements and creates new ones if needed.
"""
# Check for NSEC in environment (use NSEC only)
nsec = os.getenv("NSEC")
if not nsec:
logger.info("Nostr private key not found (NSEC), skipping NIP-91 announcement")
return
# Convert NSEC to keypair
keypair = nsec_to_keypair(nsec)
if not keypair:
logger.error("Failed to parse NSEC, skipping NIP-91 announcement")
return
private_key_hex, public_key_hex = keypair
logger.info(f"Using Nostr pubkey: {public_key_hex}")
# Configure relays first (RELAYS only)
relay_urls_env = os.getenv("RELAYS") or ""
logger.debug(f"Configured relays: {relay_urls_env}")
relay_urls = [url.strip() for url in relay_urls_env.split(",") if url.strip()]
if not relay_urls:
relay_urls = [
"wss://relay.nostr.band",
"wss://relay.damus.io",
"wss://nos.lol",
]
# Determine a stable provider_id
provider_id = await _determine_provider_id(public_key_hex, relay_urls)
logger.info(f"Using provider_id: {provider_id}")
# Core settings only (no ROUTSTR_* vars)
base_url = os.getenv("HTTP_URL")
onion_url = os.getenv("ONION_URL")
if not onion_url:
discovered = discover_onion_url_from_tor()
if discovered:
onion_url = discovered
logger.info(f"Discovered onion URL via Tor volume: {onion_url}")
provider_name = os.getenv("NAME", "Routstr Proxy")
provider_about = os.getenv("DESCRIPTION", "Privacy-preserving AI proxy via Nostr")
# Mint URL optional: first CASHU_MINTS entry if available
cashu_mints = [
m.strip() for m in os.getenv("CASHU_MINTS", "").split(",") if m.strip()
]
mint_url = cashu_mints[0] if cashu_mints else None
# Build endpoint URLs (skip defaults like localhost)
endpoint_urls: list[str] = []
if base_url and base_url.strip() and base_url.strip() != "http://localhost:8000":
endpoint_urls.append(base_url.strip())
if onion_url and onion_url.strip():
endpoint_urls.append(onion_url.strip())
if not endpoint_urls:
logger.warning(
"No valid endpoints configured (HTTP_URL/ONION_URL). Skipping NIP-91 publish."
)
return
# Get supported models
supported_models = [model.id for model in MODELS]
if not supported_models:
logger.warning("No models loaded, will announce with empty model list")
supported_models = []
# Build metadata
metadata = {
"name": provider_name,
"about": provider_about,
}
# Create the candidate event that we would publish
version_str = get_app_version()
candidate_event = create_nip91_event(
private_key_hex=private_key_hex,
provider_id=provider_id,
endpoint_urls=endpoint_urls,
supported_models=supported_models,
mint_url=mint_url,
version=version_str,
metadata=metadata,
)
# Fetch existing events for this provider_id
existing_events: list[dict[str, Any]] = []
for relay_url in relay_urls:
events = await query_nip91_events(relay_url, public_key_hex, provider_id)
existing_events.extend(events)
# Decide whether to publish: publish if none exist or any differ from candidate
found_any = len(existing_events) > 0
all_match = found_any and all(
events_semantically_equal(ev, candidate_event) for ev in existing_events
)
if not all_match:
logger.debug(
"No matching NIP-91 announcement found or differences detected; publishing update"
)
success_count = 0
for relay_url in relay_urls:
if await publish_to_relay(relay_url, candidate_event):
success_count += 1
logger.info(
f"Published NIP-91 announcement to {success_count}/{len(relay_urls)} relays"
)
else:
logger.debug(
"Matching NIP-91 announcement already present; skipping publish on startup"
)
# Re-announce periodically (every 24 hours)
announcement_interval = int(
os.getenv("NIP91_ANNOUNCEMENT_INTERVAL", str(24 * 60 * 60))
)
while True:
try:
await asyncio.sleep(announcement_interval)
# Build fresh candidate event for comparison
version_str = get_app_version()
candidate_event = create_nip91_event(
private_key_hex=private_key_hex,
provider_id=provider_id,
endpoint_urls=endpoint_urls,
supported_models=[model.id for model in MODELS],
mint_url=mint_url,
version=version_str,
metadata=metadata,
)
# Fetch existing events for this provider_id
existing_events = []
for relay_url in relay_urls:
events = await query_nip91_events(
relay_url, public_key_hex, provider_id
)
existing_events.extend(events)
found_any = len(existing_events) > 0
all_match = found_any and all(
events_semantically_equal(ev, candidate_event) for ev in existing_events
)
if all_match:
logger.debug(
"Matching NIP-91 announcement already present; skipping periodic re-announce"
)
continue
logger.debug(
f"Re-announcing provider due to differences or absence: {candidate_event['id']}"
)
for relay_url in relay_urls:
await publish_to_relay(relay_url, candidate_event)
except asyncio.CancelledError:
logger.info("NIP-91 announcement task cancelled")
break
except Exception as e:
logger.error(f"Error in NIP-91 announcement loop: {e}")
# Continue running despite errors
-268
View File
@@ -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())
+57 -35
View File
@@ -26,13 +26,25 @@ async def test_providers_endpoint_default_response(
mock_events: list[dict[str, Any]] = [
{
"id": "event1",
"content": "Check out this provider: http://provider1.onion",
"pubkey": "test_pubkey1",
"kind": 38421, # NIP-91 event kind
"created_at": 1234567890,
"content": '{"name": "Provider 1", "about": "Test provider 1"}',
"tags": [
["d", "provider1"],
["u", "http://provider1.onion"],
],
},
{
"id": "event2",
"content": "Another provider at http://provider2.onion is good",
"pubkey": "test_pubkey2",
"kind": 38421, # NIP-91 event kind
"created_at": 1234567891,
"content": '{"name": "Provider 2", "about": "Test provider 2"}',
"tags": [
["d", "provider2"],
["u", "http://provider2.onion"],
],
},
]
@@ -60,10 +72,11 @@ async def test_providers_endpoint_default_response(
assert "providers" in data
assert isinstance(data["providers"], list)
# In default format, should return list of provider URLs (strings)
# In default format, should return list of provider objects
for provider in data["providers"]:
assert isinstance(provider, str)
assert provider.endswith(".onion")
assert isinstance(provider, dict)
assert "endpoint_url" in provider
assert provider["endpoint_url"].endswith(".onion")
# Verify no database state changes
diff = await db_snapshot.diff()
@@ -86,8 +99,14 @@ async def test_providers_endpoint_with_include_json(
mock_events: list[dict[str, Any]] = [
{
"id": "event1",
"content": "Provider info: http://test-provider.onion",
"pubkey": "test_pubkey",
"kind": 38421, # NIP-91 event kind
"created_at": 1234567890,
"content": '{"name": "Test Provider", "about": "A test provider"}',
"tags": [
["d", "test-provider"],
["u", "http://test-provider.onion"],
],
}
]
@@ -117,15 +136,20 @@ async def test_providers_endpoint_with_include_json(
assert "providers" in data
assert isinstance(data["providers"], list)
# With include_json=true, should return list of dictionaries
for provider in data["providers"]:
assert isinstance(provider, dict)
# Each provider should be in format {url: json_data}
assert len(provider) == 1
url = list(provider.keys())[0]
json_data = provider[url]
assert url.endswith(".onion")
assert isinstance(json_data, dict)
# With include_json=true, should return list of dictionaries with provider and health info
for provider_data in data["providers"]:
assert isinstance(provider_data, dict)
# Each provider should have 'provider' and 'health' keys
assert "provider" in provider_data
assert "health" in provider_data
provider_info = provider_data["provider"]
assert "endpoint_url" in provider_info
assert provider_info["endpoint_url"].endswith(".onion")
health_info = provider_data["health"]
assert isinstance(health_info, dict)
assert "status_code" in health_info
# Verify no database state changes
diff = await db_snapshot.diff()
@@ -141,20 +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,
"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"],
],
}
]
@@ -190,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
@@ -239,23 +261,23 @@ async def test_providers_endpoint_offline_providers(
{
"id": "event1",
"pubkey": "healthy_provider_pubkey",
"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": 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"],
],
},
]
@@ -323,23 +345,23 @@ async def test_providers_endpoint_duplicate_urls(
{
"id": "event1",
"pubkey": "provider_pubkey",
"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": 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"],
],
},
]
Generated
+1343 -1341
View File
File diff suppressed because it is too large Load Diff