mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 12:28:22 +00:00
Remove RIP-02 support, migrate to NIP-91 provider discovery protocol
Co-authored-by: db2002dominic <db2002dominic@gmail.com>
This commit is contained in:
co-authored by
db2002dominic
parent
8dc683e6ed
commit
6b1f432c58
@@ -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
|
||||
|
||||
|
||||
+8
-36
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
@@ -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"],
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user