From 72b6d5afbdbdc106b187f67860d8e98f34614b26 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Fri, 1 Aug 2025 22:31:07 -0300 Subject: [PATCH 01/16] rm --- .todo | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .todo diff --git a/.todo b/.todo deleted file mode 100644 index 20a2f552..00000000 --- a/.todo +++ /dev/null @@ -1,5 +0,0 @@ -- test if currency and payment amount is correct -- test payout - -- make tor work -- \ No newline at end of file From c6d7939d0abfa9f831ae745ed85386ac0a209ed3 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Fri, 1 Aug 2025 22:31:18 -0300 Subject: [PATCH 02/16] update examples --- .env.example | 36 +++++--------- models.example.json | 118 ++++++++++++++++++++++++++++++++++++++------ 2 files changed, 115 insertions(+), 39 deletions(-) diff --git a/.env.example b/.env.example index 8b2dbf96..f3f2b77d 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,4 @@ NAME = "Your Routstr Proxy Name" - DESCRIPTION = "A short Description" # Any openai-compatible api endpoint @@ -9,40 +8,27 @@ UPSTREAM_API_KEY="sk-21212121212121212121212121212121" # Lightning address used to receive funds RECEIVE_LN_ADDRESS="shroominic@walletofsatoshi.com" -# A prepaid key you can use when you are using it yourself or while testing. Api-key doesn't need to be in any specific format but it should start with "sk-"" -PREPAID_API_KEY="sk-" # Add any string/hash here. Replace with a new string for a new API -PREPAID_BALANCE="10000" # 10k sats - # When your cashu balance reaches this number of sats, send the funds to RECEIVE_LN_ADDRESS. MINIMUM_PAYOUT = "100" -# Costs in Sats, if MODEL_BASED_PRICING is set to false -COST_PER_REQUEST="10" -COST_PER_1K_INPUT_TOKENS = "0" -COST_PER_1K_OUTPUT_TOKENS = "0" - # If set to true, pricing is loaded from the file specified by MODELS_PATH # Defaults to "models.json" and falls back to "models.example.json" if missing -MODEL_BASED_PRICING = "false" +MODEL_BASED_PRICING = "true" # MODELS_PATH="models.json" -# Time in seconds between each automatically refunding funds to users whose API keys have expired -# Setting this to "0" disables automatic refunds -REFUND_PROCESSING_INTERVAL = "3600" - +# Costs in Sats, if MODEL_BASED_PRICING is set to false +# COST_PER_REQUEST="10" +# COST_PER_1K_INPUT_TOKENS = "0" +# COST_PER_1K_OUTPUT_TOKENS = "0" # password used to log into admin interface -ADMIN_PASSWORD="XXX" +ADMIN_PASSWORD="CHANGE-THIS" -# NPUB of Nostr account -NSEC="" -NPUB="npub..." +# Public Endpoint +HTTP_URL="https://your.domain.com" -# Not used currently -HTTP_URL="" +# Tor Endpoint (copy from docker logs) +ONION_URL=".onion" -# Not used currently -ONION_URL="XXX.onion" - -RELAYS="wss://relay.damus.io,wss://relay.nostr.band" +RELAYS="wss://relay.routstr.com,wss://relay.nostr.band" CASHU_MINTS="https://mint.minibits.cash/Bitcoin,https://mint.cubabitcoin.org" diff --git a/models.example.json b/models.example.json index a180e83b..e503f0b8 100644 --- a/models.example.json +++ b/models.example.json @@ -1,11 +1,12 @@ { "models": [ { - "id": "google/gemini-2.5-pro-preview", + "id": "google/gemini-2.5-flash", + "canonical_slug": "google/gemini-2.5-flash", "hugging_face_id": "", - "name": "Google: Gemini 2.5 Pro Preview 06-05", - "created": 1749137257, - "description": "Gemini 2.5 Pro is Google\u2019s state-of-the-art AI model designed for advanced reasoning, coding, mathematics, and scientific tasks. It employs \u201cthinking\u201d capabilities, enabling it to reason through responses with enhanced accuracy and nuanced context handling. Gemini 2.5 Pro achieves top-tier performance on multiple benchmarks, including first-place positioning on the LMArena leaderboard, reflecting superior human-preference alignment and complex problem-solving abilities.\n", + "name": "Google: Gemini 2.5 Flash", + "created": 1750172488, + "description": "Gemini 2.5 Flash is Google's state-of-the-art workhorse model, specifically designed for advanced reasoning, coding, mathematics, and scientific tasks. It includes built-in \"thinking\" capabilities, enabling it to provide responses with greater accuracy and nuanced context handling. \n\nAdditionally, Gemini 2.5 Flash is configurable through the \"max tokens for reasoning\" parameter, as described in the documentation (https://openrouter.ai/docs/use-cases/reasoning-tokens#max-tokens-for-reasoning).", "context_length": 1048576, "architecture": { "modality": "text+image->text", @@ -21,18 +22,107 @@ "instruct_type": null }, "pricing": { - "prompt": "0.00000125", - "completion": "0.00001", + "prompt": "0.0000003", + "completion": "0.0000025", "request": "0", - "image": "0.00516", + "image": "0.001238", "web_search": "0", "internal_reasoning": "0", - "input_cache_read": "0.00000031", - "input_cache_write": "0.000001625" + "input_cache_read": "0.000000075", + "input_cache_write": "0.0000003833" }, "top_provider": { "context_length": 1048576, - "max_completion_tokens": 65536, + "max_completion_tokens": 65535, + "is_moderated": false + }, + "per_request_limits": null, + "supported_parameters": [ + "max_tokens", + "temperature", + "top_p", + "tools", + "tool_choice", + "stop", + "response_format", + "structured_outputs" + ] + }, + { + "id": "openai/o3-pro", + "canonical_slug": "openai/o3-pro-2025-06-10", + "hugging_face_id": "", + "name": "OpenAI: o3 Pro", + "created": 1749598352, + "description": "The o-series of models are trained with reinforcement learning to think before they answer and perform complex reasoning. The o3-pro model uses more compute to think harder and provide consistently better answers.\n\nNote that BYOK is required for this model. Set up here: https://openrouter.ai/settings/integrations", + "context_length": 200000, + "architecture": { + "modality": "text+image->text", + "input_modalities": [ + "text", + "file", + "image" + ], + "output_modalities": [ + "text" + ], + "tokenizer": "Other", + "instruct_type": null + }, + "pricing": { + "prompt": "0.00002", + "completion": "0.00008", + "request": "0", + "image": "0.0153", + "web_search": "0", + "internal_reasoning": "0" + }, + "top_provider": { + "context_length": 200000, + "max_completion_tokens": 100000, + "is_moderated": true + }, + "per_request_limits": null, + "supported_parameters": [ + "tools", + "tool_choice", + "seed", + "max_tokens", + "response_format", + "structured_outputs" + ] + }, + { + "id": "x-ai/grok-3-mini", + "canonical_slug": "x-ai/grok-3-mini", + "hugging_face_id": "", + "name": "xAI: Grok 3 Mini", + "created": 1749583245, + "description": "A lightweight model that thinks before responding. Fast, smart, and great for logic-based tasks that do not require deep domain knowledge. The raw thinking traces are accessible.", + "context_length": 131072, + "architecture": { + "modality": "text->text", + "input_modalities": [ + "text" + ], + "output_modalities": [ + "text" + ], + "tokenizer": "Grok", + "instruct_type": null + }, + "pricing": { + "prompt": "0.0000003", + "completion": "0.0000005", + "request": "0", + "image": "0", + "web_search": "0", + "internal_reasoning": "0", + "input_cache_read": "0.000000075" + }, + "top_provider": { + "context_length": 131072, + "max_completion_tokens": null, "is_moderated": false }, "per_request_limits": null, @@ -45,11 +135,11 @@ "reasoning", "include_reasoning", "structured_outputs", - "response_format", "stop", - "frequency_penalty", - "presence_penalty", - "seed" + "seed", + "logprobs", + "top_logprobs", + "response_format" ] } ] From b313017492d901a104abd8e76b055f14942b62b9 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Fri, 1 Aug 2025 22:31:40 -0300 Subject: [PATCH 03/16] fix mint advertisment --- router/main.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/router/main.py b/router/main.py index 9e1b87fa..4562ee3d 100644 --- a/router/main.py +++ b/router/main.py @@ -30,8 +30,6 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: await init_db() logger.info("Database initialized successfully") - logger.info("Wallet initialized successfully") - pricing_task = asyncio.create_task(update_sats_pricing()) refund_task = asyncio.create_task(check_for_refunds()) payout_task = asyncio.create_task(periodic_payout()) @@ -85,11 +83,6 @@ app.add_middleware( allow_headers=["*"], ) -logger.info( - "CORS middleware configured", - extra={"allowed_origins": os.environ.get("CORS_ORIGINS", "*").split(",")}, -) - @app.get("/") async def info() -> dict: @@ -99,7 +92,7 @@ async def info() -> dict: "description": app.description, "version": __version__, "npub": os.environ.get("NPUB", ""), - "mint": os.environ.get("MINT", ""), + "mints": os.environ.get("CASHU_MINTS", "").split(","), "http_url": os.environ.get("HTTP_URL", ""), "onion_url": os.environ.get("ONION_URL", ""), "models": MODELS, From 13d6d5bd6065c76ae684f9a8ec559cdb39aeec1a Mon Sep 17 00:00:00 2001 From: Shroominic Date: Sat, 2 Aug 2025 00:05:37 -0300 Subject: [PATCH 04/16] fix cashuA parsing --- router/payment/helpers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/router/payment/helpers.py b/router/payment/helpers.py index 92690723..d04216d9 100644 --- a/router/payment/helpers.py +++ b/router/payment/helpers.py @@ -113,7 +113,7 @@ def check_token_balance(headers: dict, body: dict) -> Literal["sat", "msat"]: try: _token = base64_token_json(cashu_token) amount = sum(p["amount"] for t in _token["token"] for p in t["proofs"]) - unit: Literal["sat", "msat"] = _token["unit"] + unit: Literal["sat", "msat"] = _token.get("unit", "sat") if unit == "sat": amount *= 1000 @@ -122,7 +122,7 @@ def check_token_balance(headers: dict, body: dict) -> Literal["sat", "msat"]: "CashuA token parsed successfully", extra={ "amount": amount, - "unit": _token["unit"], + "unit": unit, "amount_msats": amount, "required_cost_msats": cost, "sufficient_balance": amount >= cost, From bc651f67e86c79bc473f00ac91e06b42f275d378 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Sat, 2 Aug 2025 00:06:20 -0300 Subject: [PATCH 05/16] update discovery --- router/discovery.py | 265 +++++++++++++++++++++++++++----------------- 1 file changed, 163 insertions(+), 102 deletions(-) diff --git a/router/discovery.py b/router/discovery.py index 2a301540..956f0312 100644 --- a/router/discovery.py +++ b/router/discovery.py @@ -2,8 +2,8 @@ import asyncio import json import os import random -import re import string +from typing import Any import httpx import websockets @@ -17,59 +17,34 @@ def generate_subscription_id() -> str: return "".join(random.choices(string.ascii_lowercase + string.digits, k=10)) -def extract_onion_urls(content: str) -> list[str]: - """Extract onion URLs from content.""" - pattern = r"http?://[a-zA-Z0-9\-._~]+\.onion" - return re.findall(pattern, content) - - -async def query_nostr_relay_with_search( - search_term: str, +async def query_nostr_relay_for_providers( relay_url: str, - kinds: list[int] | None = None, + pubkey: str | None = None, limit: int = 1000, timeout: int = 30, -) -> list[dict]: +) -> list[dict[str, Any]]: """ - Query a Nostr relay and filter for events containing a search term. + Query a Nostr relay for provider announcements using RIP-02 spec. + Searches for kind 31338 events (Routstr Provider Announcements). """ - if kinds is None: - kinds = [1] - events = [] - # If searching for an npub mention, try tag-based search first - if search_term.startswith("nostr:npub"): - # Extract the npub and convert to hex - npub = search_term.replace("nostr:", "") - try: - # Convert npub to hex (you might need to implement or import this) - # For now, try tag-based search with the npub - filter_obj = { - "kinds": kinds, - "limit": limit, - "#p": [npub], # Posts that tag this pubkey - } - except Exception: - # If conversion fails, try regular search - filter_obj = { - "kinds": kinds, - "limit": limit, - } - else: - # Try relay's search functionality (NIP-50) - filter_obj = { - "kinds": kinds, - "search": search_term, - "limit": limit, - } + # Build filter according to RIP-02 spec + filter_obj: dict[str, Any] = { + "kinds": [31338], # RIP-02 Provider Announcement events + "limit": limit, + } + + # If specific pubkey provided, filter by author + if pubkey: + filter_obj["authors"] = [pubkey] sub_id = generate_subscription_id() req_message = json.dumps(["REQ", sub_id, filter_obj]) try: async with websockets.connect(relay_url, timeout=timeout) as websocket: - print(f"Connected to relay, sending request with filter: {filter_obj}") + print(f"Connected to relay, searching for kind 31338 events") await websocket.send(req_message) while True: @@ -78,25 +53,14 @@ async def query_nostr_relay_with_search( data = json.loads(message) if data[0] == "EVENT" and data[1] == sub_id: - # For tag-based search, also check content - if search_term.startswith("nostr:npub"): - if search_term.lower() in data[2]["content"].lower(): - print(f"Found matching event: {data[2]['id']}") - events.append(data[2]) - else: - print(f"Found matching event: {data[2]['id']}") - events.append(data[2]) + event = data[2] + print(f"Found provider announcement: {event['id']}") + events.append(event) elif data[0] == "EOSE" and data[1] == sub_id: print("Received EOSE message") break elif data[0] == "NOTICE": print(f"Relay notice: {data[1]}") - # If search not supported, could break and try different approach - if "unrecognised filter item" in data[1] and "search" in str( - filter_obj - ): - print("Search not supported on this relay") - break except asyncio.TimeoutError: print("Timeout waiting for message") @@ -110,61 +74,159 @@ async def query_nostr_relay_with_search( except Exception as e: print(f"Query failed: {e}") - print(f"Query complete. Found {len(events)} matching events") + print(f"Query complete. Found {len(events)} provider announcements") return events -async def get_cache() -> list[dict]: +def parse_provider_announcement(event: dict[str, Any]) -> dict[str, Any] | None: + """ + Parse a kind 31338 provider announcement event according to RIP-02 spec. + Returns structured provider data or None if invalid. + """ + try: + # Extract required tags according to RIP-02 + tags = event.get("tags", []) + + # Find required tags + endpoint_url = None + provider_name = None + d_tag = None + + for tag in tags: + if len(tag) >= 2: + if tag[0] == "endpoint": + endpoint_url = tag[1] + elif tag[0] == "name": + provider_name = tag[1] + elif tag[0] == "d": + d_tag = tag[1] + + # Validate required fields + if not endpoint_url or not provider_name or not d_tag: + print( + f"Invalid provider announcement - missing required tags: {event['id']}" + ) + return None + + # Extract optional tags + description = None + contact = None + pricing_url = None + supported_models = [] + + 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]) + + return { + "id": event["id"], + "pubkey": event["pubkey"], + "created_at": event["created_at"], + "d_tag": d_tag, + "endpoint_url": endpoint_url, + "name": provider_name, + "description": description, + "contact": contact, + "pricing_url": pricing_url, + "supported_models": supported_models, + "content": event.get("content", ""), + } + + except Exception as e: + print(f"Error parsing provider announcement {event.get('id', 'unknown')}: {e}") + return None + + +async def get_cache() -> list[dict[str, Any]]: return [] # TODO: Implement cache -async def fetch_onion(provider: str) -> dict: - """Check if an onion service is healthy by making a GET request to its root.""" +async def fetch_provider_health(endpoint_url: str) -> dict[str, Any]: + """Check if a provider endpoint is healthy by making a GET request.""" try: - # Get Tor proxy URL from environment variable, default to local Tor SOCKS5 proxy - tor_proxy = os.getenv("TOR_PROXY_URL", "socks5://127.0.0.1:9050") + # Determine if we need Tor proxy based on .onion domain + is_onion = ".onion" in endpoint_url + + # Set up client arguments conditionally + proxies = None + if is_onion: + # Get Tor proxy URL from environment variable + tor_proxy = os.getenv("TOR_PROXY_URL", "socks5://127.0.0.1:9050") + proxies = {"http://": tor_proxy, "https://": tor_proxy} # type: ignore[assignment] - # Configure httpx to use Tor SOCKS5 proxy async with httpx.AsyncClient( - proxies={"http://": tor_proxy, "https://": tor_proxy}, # type: ignore timeout=httpx.Timeout(30.0), follow_redirects=True, + proxies=proxies, # type: ignore[arg-type] ) as client: - response = await client.get(provider) - # Consider 2xx and 3xx status codes as healthy - return {"status_code": response.status_code, "json": response.json()} - except Exception: - # Any exception means the service is not healthy - return {"status_code": 500, "json": {"error": "Failed to fetch onion"}} + # Try to fetch models endpoint first (common for AI providers) + models_url = f"{endpoint_url.rstrip('/')}/v1/models" + try: + response = await client.get(models_url) + if response.status_code == 200: + return { + "status_code": response.status_code, + "endpoint": "models", + "json": response.json(), + } + except Exception: + pass + + # Fallback to root endpoint + response = await client.get(endpoint_url) + return { + "status_code": response.status_code, + "endpoint": "root", + "json": response.json() + if response.headers.get("content-type", "").startswith( + "application/json" + ) + else {"message": "OK"}, + } + + except Exception as e: + return { + "status_code": 500, + "endpoint": "error", + "json": {"error": f"Failed to fetch provider: {str(e)}"}, + } @providers_router.get("/") -async def get_providers(include_json: bool = False) -> dict[str, list[dict | str]]: - npub = "npub130mznv74rxs032peqym6g3wqavh472623mt3z5w73xq9r6qqdufs7ql29s" +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. - # Relays that support NIP-50 text search - search_relays = [ - "wss://relay.nostr.band", # Known to support search - "wss://nostr.wine", # Known to support search + Reference: https://github.com/Routstr/protocol/blob/main/RIP-02.md + """ + # Default relays for provider discovery + discovery_relays = [ + "wss://relay.nostr.band", "wss://relay.damus.io", - "wss://nos.lol", + "wss://relay.routstr.com", ] - # Search for the mention format that appears in posts - search_term = f"nostr:{npub}" - all_events = [] event_ids = set() # To avoid duplicates - # Try multiple relays - for relay_url in search_relays: - print(f"\nTrying relay: {relay_url}") + # Query multiple relays for provider announcements + for relay_url in discovery_relays: + print(f"\nQuerying relay for providers: {relay_url}") try: - events = await query_nostr_relay_with_search( - search_term=search_term, + events = await query_nostr_relay_for_providers( relay_url=relay_url, - kinds=[1], # Text notes - limit=500, + pubkey=pubkey, + limit=100, ) # Add unique events @@ -173,35 +235,34 @@ async def get_providers(include_json: bool = False) -> dict[str, list[dict | str event_ids.add(event["id"]) all_events.append(event) - print(f"Got {len(events)} events from {relay_url}") - - # If we have enough events, we can stop - if len(all_events) >= 100: - break + print(f"Got {len(events)} provider announcements from {relay_url}") except Exception as e: print(f"Failed to query {relay_url}: {e}") continue - print(f"Found {len(all_events)} total unique events mentioning routstr") + print(f"Found {len(all_events)} total unique provider announcements") + # Parse provider announcements according to RIP-02 providers = [] for event in all_events: - onion_urls = extract_onion_urls(event["content"]) - providers.extend(onion_urls) + parsed_provider = parse_provider_announcement(event) + if parsed_provider: + providers.append(parsed_provider) - unique_providers = list(set(providers)) + print(f"Parsed {len(providers)} valid provider announcements") - print(f"Found {len(unique_providers)} unique onion URLs") - print(unique_providers) - - healthy_providers: list[dict | str] = [] - for provider in unique_providers: - response = await fetch_onion(provider) + # Check provider health if requested + healthy_providers: list[dict[str, Any]] = [] + for provider in providers: + endpoint_url = provider["endpoint_url"] if include_json: - healthy_providers.append({provider: response["json"]}) + health_check = await fetch_provider_health(endpoint_url) + provider_data = {"provider": provider, "health": health_check} + healthy_providers.append(provider_data) else: + # Just return the provider info without health check healthy_providers.append(provider) return {"providers": healthy_providers} From c81f024d7df2b7680a57951f709e950d65e3860d Mon Sep 17 00:00:00 2001 From: Shroominic Date: Sat, 2 Aug 2025 00:06:31 -0300 Subject: [PATCH 06/16] wip provider publish script --- scripts/publish_provider.py | 268 ++++++++++++++++++++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 scripts/publish_provider.py diff --git a/scripts/publish_provider.py b/scripts/publish_provider.py new file mode 100644 index 00000000..a51e80e4 --- /dev/null +++ b/scripts/publish_provider.py @@ -0,0 +1,268 @@ +#!/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 hashlib +import json +import time +import asyncio +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()) From 7f9555227f721e1e1795d60c9bd0e6edabc31eff Mon Sep 17 00:00:00 2001 From: Shroominic Date: Sat, 2 Aug 2025 00:06:46 -0300 Subject: [PATCH 07/16] bump stable release version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 327ef826..ef3957b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "routstr" -version = "0.0.1" +version = "0.1.0" description = "Payment proxy for your LLM endpoint using cashu and nostr." readme = "README.md" requires-python = ">=3.11" From bc12d23f5b5226cdd1f7045f011fe7b5e24a1065 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Sat, 2 Aug 2025 00:08:16 -0300 Subject: [PATCH 08/16] fix linting errors --- router/discovery.py | 2 +- scripts/publish_provider.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/router/discovery.py b/router/discovery.py index 956f0312..49ead23d 100644 --- a/router/discovery.py +++ b/router/discovery.py @@ -44,7 +44,7 @@ async def query_nostr_relay_for_providers( try: async with websockets.connect(relay_url, timeout=timeout) as websocket: - print(f"Connected to relay, searching for kind 31338 events") + print("Connected to relay, searching for kind 31338 events") await websocket.send(req_message) while True: diff --git a/scripts/publish_provider.py b/scripts/publish_provider.py index a51e80e4..847c64ab 100644 --- a/scripts/publish_provider.py +++ b/scripts/publish_provider.py @@ -7,10 +7,10 @@ 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 -import asyncio from typing import Any import secp256k1 From a7831952b9fde24338257159f21566b0564c713f Mon Sep 17 00:00:00 2001 From: Shroominic Date: Sat, 2 Aug 2025 12:33:42 -0300 Subject: [PATCH 09/16] fix tests --- tests/test_main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_main.py b/tests/test_main.py index 943571b6..2f2f1eef 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -13,7 +13,7 @@ async def test_root_endpoint(async_client: AsyncClient) -> None: "NAME": "TestRoutstrNode", "DESCRIPTION": "Test Node", "NPUB": "npub1test", - "MINT": "https://test.mint.com", + "CASHU_MINTS": "https://test.mint.com,https://test.mint2.com", "HTTP_URL": "http://test.example.com", "ONION_URL": "http://test.onion", } @@ -28,7 +28,7 @@ async def test_root_endpoint(async_client: AsyncClient) -> None: assert "name" in data assert "description" in data assert "npub" in data - assert "mint" in data + assert "mints" in data assert "http_url" in data assert "onion_url" in data From 6bc963de77cf468ae25b22dc8e71af68d3de0244 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Sat, 2 Aug 2025 13:10:56 -0300 Subject: [PATCH 10/16] rm comments --- router/wallet.py | 40 ---------------------------------------- 1 file changed, 40 deletions(-) diff --git a/router/wallet.py b/router/wallet.py index 1856efbb..11f97567 100644 --- a/router/wallet.py +++ b/router/wallet.py @@ -8,18 +8,6 @@ from cashu.wallet.wallet import Wallet from .db import ApiKey, AsyncSession from .logging import get_logger -# from .cashu import ( -# credit_balance, -# delete_key_if_zero_balance, -# refund_balance, -# wallet, -# ) -# from .cashu import ( -# check_for_refunds, -# init_wallet, -# periodic_payout, -# ) - logger = get_logger(__name__) CurrencyUnit = Literal["sat", "msat"] @@ -93,34 +81,6 @@ async def swap_to_primary_mint( quote_id=melt_quote.quote, ) - _ = await wallet.mint(token_obj.amount, mint_quote.quote) - - return token_obj.amount, "sat", PRIMARY_MINT_URL - - -# insert initial token state here to reduce db calls -# async def create_refund_token( -# amount: int, unit: CurrencyUnit, mint_url: str | None = None -# ) -> str: -# wallet = await Wallet.with_db( -# mint_url, DATABASE_URL, load_all_keysets=True, unit=unit -# ) -# if wallet.balance_per_minturl(unit=unit)[mint_url] < amount: -# raise ValueError("Wallet has no balance") -# if mint_url is None: -# mint_url = wallet.mint_urls[0] -# return await wallet._make_token(amount, unit=unit, mint_url=mint_url) - - -# async def redeem_token(token: str) -> Token: -# token_obj = deserialize_token_from_string(token) -# wallet = await Wallet.with_db( -# token_obj.mint, -# DATABASE_URL, -# load_all_keysets=True, -# unit=token_obj.unit, -# ) -# return await redeem_universal(wallet, token_obj) async def credit_balance(cashu_token: str, key: ApiKey, session: AsyncSession) -> int: From d94a27d16edc981ab443bc5c0e8dd53bc7b4a707 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Sat, 2 Aug 2025 13:11:21 -0300 Subject: [PATCH 11/16] rm unused --- router/wallet.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/router/wallet.py b/router/wallet.py index 11f97567..65fe211e 100644 --- a/router/wallet.py +++ b/router/wallet.py @@ -107,11 +107,6 @@ async def check_for_refunds() -> None: print("check_for_refunds, temp not implemented") -async def init_wallet() -> None: - balance = await get_balance("sat") - print(f"init_wallet, balance: {balance}") - - async def periodic_payout() -> None: print("periodic_payout, temp not implemented") From 2a56998450b21fa702b84f055a77530448ad8b4b Mon Sep 17 00:00:00 2001 From: Shroominic Date: Sat, 2 Aug 2025 14:04:37 -0300 Subject: [PATCH 12/16] refactor names and folders --- router/__init__.py | 2 +- router/auth.py | 4 ++-- router/{account.py => balance.py} | 20 ++++++++++------ router/core/__init__.py | 3 +++ router/{ => core}/admin.py | 4 ++-- router/{ => core}/db.py | 0 router/{ => core}/logging.py | 5 ++++ router/{ => core}/main.py | 16 +++++++------ router/payment/__init__.py | 8 +++++++ router/payment/cost_caculation.py | 4 ++-- router/payment/helpers.py | 4 ++-- router/{ => payment}/models.py | 1 - router/{ => payment}/price.py | 2 +- router/payment/x_cashu.py | 9 +++++-- router/proxy.py | 4 ++-- router/wallet.py | 39 ++++++++++++++++++++----------- tests/README.md | 3 +-- tests/conftest.py | 8 +++---- tests/test_models.py | 8 +++---- 19 files changed, 92 insertions(+), 52 deletions(-) rename router/{account.py => balance.py} (86%) create mode 100644 router/core/__init__.py rename router/{ => core}/admin.py (99%) rename router/{ => core}/db.py (100%) rename router/{ => core}/logging.py (97%) rename router/{ => core}/main.py (87%) rename router/{ => payment}/models.py (99%) rename router/{ => payment}/price.py (99%) diff --git a/router/__init__.py b/router/__init__.py index 9c697e55..7a1bc151 100644 --- a/router/__init__.py +++ b/router/__init__.py @@ -2,6 +2,6 @@ import dotenv dotenv.load_dotenv() -from .main import app as fastapi_app # noqa +from .core.main import app as fastapi_app # noqa __all__ = ["fastapi_app"] diff --git a/router/auth.py b/router/auth.py index b51a069d..8c6cd04d 100644 --- a/router/auth.py +++ b/router/auth.py @@ -4,8 +4,8 @@ from typing import Optional from fastapi import HTTPException from sqlmodel import col, update -from .db import ApiKey, AsyncSession -from .logging import get_logger +from .core import get_logger +from .core.db import ApiKey, AsyncSession from .payment.cost_caculation import ( CostData, CostDataError, diff --git a/router/account.py b/router/balance.py similarity index 86% rename from router/account.py rename to router/balance.py index c4389a2a..0592b249 100644 --- a/router/account.py +++ b/router/balance.py @@ -3,10 +3,11 @@ from typing import Annotated, NoReturn from fastapi import APIRouter, Depends, Header, HTTPException from .auth import validate_bearer_key -from .db import ApiKey, AsyncSession, get_session +from .core.db import ApiKey, AsyncSession, get_session from .wallet import credit_balance, send_to_lnurl, send_token -wallet_router = APIRouter(prefix="/v1/wallet") +router = APIRouter() +balance_router = APIRouter(prefix="/v1/balance") async def get_key_from_header( @@ -23,7 +24,7 @@ async def get_key_from_header( # TODO: remove this endpoint when frontend is updated -@wallet_router.get("/") +@router.get("/", include_in_schema=False) async def account_info(key: ApiKey = Depends(get_key_from_header)) -> dict: return { "api_key": "sk-" + key.hashed_key, @@ -31,7 +32,7 @@ async def account_info(key: ApiKey = Depends(get_key_from_header)) -> dict: } -@wallet_router.get("/info") +@router.get("/info") async def wallet_info(key: ApiKey = Depends(get_key_from_header)) -> dict: return { "api_key": "sk-" + key.hashed_key, @@ -39,7 +40,7 @@ async def wallet_info(key: ApiKey = Depends(get_key_from_header)) -> dict: } -@wallet_router.post("/topup") +@router.post("/topup") async def topup_wallet_endpoint( cashu_token: str, key: ApiKey = Depends(get_key_from_header), @@ -49,7 +50,7 @@ async def topup_wallet_endpoint( return {"msats": amount_msats} -@wallet_router.post("/refund") +@router.post("/refund") async def refund_wallet_endpoint( key: ApiKey = Depends(get_key_from_header), session: AsyncSession = Depends(get_session), @@ -82,7 +83,7 @@ async def refund_wallet_endpoint( return result -@wallet_router.api_route( +@router.api_route( "/{path:path}", methods=["GET", "POST", "PUT", "DELETE"], include_in_schema=False, @@ -92,3 +93,8 @@ async def wallet_catch_all(path: str) -> NoReturn: raise HTTPException( status_code=404, detail="Not found check /docs for available endpoints" ) + + +balance_router.include_router(router) +deprecated_wallet_router = APIRouter(prefix="/v1/wallet", include_in_schema=False) +deprecated_wallet_router.include_router(router) diff --git a/router/core/__init__.py b/router/core/__init__.py new file mode 100644 index 00000000..6affb142 --- /dev/null +++ b/router/core/__init__.py @@ -0,0 +1,3 @@ +from .logging import get_logger + +__all__ = ["get_logger"] diff --git a/router/admin.py b/router/core/admin.py similarity index 99% rename from router/admin.py rename to router/core/admin.py index 856bffa9..b511e449 100644 --- a/router/admin.py +++ b/router/core/admin.py @@ -6,10 +6,10 @@ from fastapi.responses import HTMLResponse from pydantic import BaseModel from sqlmodel import select +from ..wallet import get_balance, send_token from .db import ApiKey, create_session -from .wallet import get_balance, send_token -admin_router = APIRouter(prefix="/admin") +admin_router = APIRouter(prefix="/admin", include_in_schema=False) class WithdrawRequest(BaseModel): diff --git a/router/db.py b/router/core/db.py similarity index 100% rename from router/db.py rename to router/core/db.py diff --git a/router/logging.py b/router/core/logging.py similarity index 97% rename from router/logging.py rename to router/core/logging.py index 5a4b019c..57c5277a 100644 --- a/router/logging.py +++ b/router/core/logging.py @@ -252,6 +252,11 @@ def setup_logging() -> None: "handlers": ["console"] if console_enabled else [], "propagate": False, }, + "watchfiles": { + "level": "WARNING", + "handlers": ["console"] if console_enabled else [], + "propagate": False, + }, }, "root": { "level": log_level, diff --git a/router/main.py b/router/core/main.py similarity index 87% rename from router/main.py rename to router/core/main.py index 4562ee3d..592ccbfd 100644 --- a/router/main.py +++ b/router/core/main.py @@ -6,14 +6,14 @@ from typing import AsyncGenerator from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from .account import wallet_router +from ..balance import balance_router, deprecated_wallet_router +from ..discovery import providers_router +from ..payment.models import MODELS, models_router, update_sats_pricing +from ..proxy import proxy_router +from ..wallet import check_for_refunds, periodic_payout from .admin import admin_router from .db import init_db -from .discovery import providers_router from .logging import get_logger, setup_logging -from .models import MODELS, models_router, update_sats_pricing -from .proxy import proxy_router -from .wallet import check_for_refunds, periodic_payout # Initialize logging first setup_logging() @@ -84,7 +84,8 @@ app.add_middleware( ) -@app.get("/") +@app.get("/", include_in_schema=False) +@app.get("/v1/info") async def info() -> dict: logger.info("Info endpoint accessed") return { @@ -101,7 +102,8 @@ async def info() -> dict: app.include_router(models_router) app.include_router(admin_router) -app.include_router(wallet_router) +app.include_router(balance_router) +app.include_router(deprecated_wallet_router) app.include_router(providers_router) app.include_router(proxy_router) diff --git a/router/payment/__init__.py b/router/payment/__init__.py index e69de29b..55f5a854 100644 --- a/router/payment/__init__.py +++ b/router/payment/__init__.py @@ -0,0 +1,8 @@ +from .cost_caculation import CostData, CostDataError, MaxCostData, calculate_cost + +__all__ = [ + "CostData", + "CostDataError", + "MaxCostData", + "calculate_cost", +] diff --git a/router/payment/cost_caculation.py b/router/payment/cost_caculation.py index 2f2bf9da..fb664e9c 100644 --- a/router/payment/cost_caculation.py +++ b/router/payment/cost_caculation.py @@ -2,8 +2,8 @@ import os from pydantic import BaseModel -from ..logging import get_logger -from ..models import MODELS +from ..core import get_logger +from .models import MODELS logger = get_logger(__name__) diff --git a/router/payment/helpers.py b/router/payment/helpers.py index d04216d9..5c265ea5 100644 --- a/router/payment/helpers.py +++ b/router/payment/helpers.py @@ -6,9 +6,9 @@ from typing import Literal import cbor2 from fastapi import HTTPException, Response -from ..logging import get_logger -from ..models import MODELS +from ..core import get_logger from .cost_caculation import COST_PER_REQUEST, MODEL_BASED_PRICING +from .models import MODELS logger = get_logger(__name__) diff --git a/router/models.py b/router/payment/models.py similarity index 99% rename from router/models.py rename to router/payment/models.py index e9af0e85..ef545c81 100644 --- a/router/models.py +++ b/router/payment/models.py @@ -144,7 +144,6 @@ async def update_sats_pricing() -> None: break -@models_router.get("/models") @models_router.get("/v1/models") async def models() -> dict: return {"data": MODELS} diff --git a/router/price.py b/router/payment/price.py similarity index 99% rename from router/price.py rename to router/payment/price.py index a14557a6..588c8094 100644 --- a/router/price.py +++ b/router/payment/price.py @@ -3,7 +3,7 @@ import os import httpx -from .logging import get_logger +from ..core import get_logger logger = get_logger(__name__) diff --git a/router/payment/x_cashu.py b/router/payment/x_cashu.py index 30ce674c..2c25ec14 100644 --- a/router/payment/x_cashu.py +++ b/router/payment/x_cashu.py @@ -6,9 +6,14 @@ import httpx from fastapi import BackgroundTasks, HTTPException, Request from fastapi.responses import Response, StreamingResponse -from ..logging import get_logger +from ..core import get_logger from ..wallet import CurrencyUnit, recieve_token, send_token -from .cost_caculation import CostData, CostDataError, MaxCostData, calculate_cost +from .cost_caculation import ( + CostData, + CostDataError, + MaxCostData, + calculate_cost, +) from .helpers import ( UPSTREAM_BASE_URL, create_error_response, diff --git a/router/proxy.py b/router/proxy.py index 2b643cb9..d89afd8f 100644 --- a/router/proxy.py +++ b/router/proxy.py @@ -13,8 +13,8 @@ from .auth import ( revert_pay_for_request, validate_bearer_key, ) -from .db import ApiKey, AsyncSession, create_session, get_session -from .logging import get_logger +from .core import get_logger +from .core.db import ApiKey, AsyncSession, create_session, get_session from .payment.helpers import ( UPSTREAM_BASE_URL, check_token_balance, diff --git a/router/wallet.py b/router/wallet.py index 65fe211e..bfd3718b 100644 --- a/router/wallet.py +++ b/router/wallet.py @@ -1,12 +1,11 @@ import os from typing import Literal -from cashu.core.base import Token +from cashu.core.base import Token, Unit from cashu.wallet.helpers import deserialize_token_from_string, receive, send from cashu.wallet.wallet import Wallet -from .db import ApiKey, AsyncSession -from .logging import get_logger +from .core import db, get_logger logger = get_logger(__name__) @@ -39,10 +38,9 @@ async def recieve_token( load_all_keysets=True, unit=token_obj.unit, ) - if token_obj.mint in TRUSTED_MINTS and token_obj.mint != PRIMARY_MINT_URL: + + if token_obj.mint not in TRUSTED_MINTS: return await swap_to_primary_mint(token_obj, wallet) - elif token_obj.mint not in TRUSTED_MINTS: - raise ValueError("Mint URL is not supported by this proxy") await receive(wallet, token_obj) return token_obj.amount, token_obj.unit, token_obj.mint @@ -61,7 +59,7 @@ async def send_token( async def swap_to_primary_mint( - token_obj: Token, wallet: Wallet + token_obj: Token, token_wallet: Wallet ) -> tuple[int, CurrencyUnit, str]: print(f"swap_to_primary_mint, token_obj: {token_obj}") if token_obj.unit == "sat": @@ -72,18 +70,33 @@ async def swap_to_primary_mint( raise ValueError("Invalid unit") estimated_fee_sat = max(amount_msat // 1000 * 0.01, 2) amount_msat_after_fee = amount_msat - estimated_fee_sat * 1000 - mint_quote = await wallet.mint_quote(amount_msat_after_fee, "sat") - melt_quote = await wallet.melt_quote(mint_quote.request, amount_msat_after_fee) - _ = await wallet.melt( + print(f"amount_msat_after_fee: {amount_msat_after_fee}") + primary_wallet = await Wallet.with_db( + PRIMARY_MINT_URL, db=".temp", load_all_keysets=True, unit="sat" + ) + await primary_wallet.load_mint_keysets() + mint_quote = await primary_wallet.mint_quote( + amount_msat_after_fee // 1000, Unit.sat + ) + print(f"mint_quote: {mint_quote}") + melt_quote = await token_wallet.melt_quote(mint_quote.request) + print(f"melt_quote: {melt_quote}") + melt_quote_resp = await token_wallet.melt( proofs=token_obj.proofs, invoice=mint_quote.request, - fee_reserve=melt_quote.fee_reserve, + fee_reserve_sat=melt_quote.fee_reserve, quote_id=melt_quote.quote, ) + print(f"melt_quote_resp: {melt_quote_resp}") + + _ = await primary_wallet.mint(amount_msat_after_fee // 1000, mint_quote.quote) + + return amount_msat_after_fee // 1000, "sat", PRIMARY_MINT_URL - -async def credit_balance(cashu_token: str, key: ApiKey, session: AsyncSession) -> int: +async def credit_balance( + cashu_token: str, key: db.ApiKey, session: db.AsyncSession +) -> int: amount, unit, mint_url = await recieve_token(cashu_token) if unit == "sat": amount = amount * 1000 diff --git a/tests/README.md b/tests/README.md index 9e40159f..72280342 100644 --- a/tests/README.md +++ b/tests/README.md @@ -28,9 +28,8 @@ To run specific test files: ```bash pytest tests/test_main.py -pytest tests/test_account.py -pytest tests/test_proxy.py pytest tests/test_models.py +pytest tests/test_proxy.py ``` To run only async tests: diff --git a/tests/conftest.py b/tests/conftest.py index d69640c2..d5c2b6e6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -37,8 +37,8 @@ TEST_ENV = { os.environ.update(TEST_ENV) # Now import modules that depend on environment variables -from router.db import get_session # noqa: E402 -from router.main import app # noqa: E402 +from router.core.db import get_session # noqa: E402 +from router.core.main import app # noqa: E402 @pytest.fixture(scope="session") @@ -79,7 +79,7 @@ async def test_session(test_engine: AsyncEngine) -> AsyncGenerator[AsyncSession, def test_client() -> Generator[TestClient, None, None]: """Create a test client for the FastAPI app.""" with patch.dict(os.environ, TEST_ENV, clear=True): - with patch("router.models.update_sats_pricing") as mock_update: + with patch("router.payment.models.update_sats_pricing") as mock_update: mock_update.return_value = None yield TestClient(app) @@ -95,7 +95,7 @@ async def async_client(test_session: AsyncSession) -> AsyncGenerator[AsyncClient # Mock startup tasks with patch.dict(os.environ, TEST_ENV, clear=True): - with patch("router.models.update_sats_pricing") as mock_update: + with patch("router.payment.models.update_sats_pricing") as mock_update: mock_update.return_value = None async with AsyncClient( diff --git a/tests/test_models.py b/tests/test_models.py index 06bb4eb8..f586fd93 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, patch import pytest -from router.models import ( +from router.payment.models import ( MODELS, Architecture, Model, @@ -49,7 +49,7 @@ async def test_update_sats_pricing_calculation(sample_model: Model) -> None: """Test that sats pricing is calculated correctly.""" # Mock the sats_usd_ask_price function with patch( - "router.models.sats_usd_ask_price", new_callable=AsyncMock + "router.payment.models.sats_usd_ask_price", new_callable=AsyncMock ) as mock_price: mock_price.return_value = 0.0001 # 1 sat = 0.0001 USD @@ -137,7 +137,7 @@ async def test_update_sats_pricing_without_top_provider() -> None: ) with patch( - "router.models.sats_usd_ask_price", new_callable=AsyncMock + "router.payment.models.sats_usd_ask_price", new_callable=AsyncMock ) as mock_price: mock_price.return_value = 0.0001 # 1 sat = 0.0001 USD @@ -189,7 +189,7 @@ async def test_update_sats_pricing_without_top_provider() -> None: async def test_update_sats_pricing_handles_errors() -> None: """Test that update_sats_pricing handles errors gracefully.""" with patch( - "router.models.sats_usd_ask_price", new_callable=AsyncMock + "router.payment.models.sats_usd_ask_price", new_callable=AsyncMock ) as mock_price: mock_price.side_effect = Exception("API Error") From 658fc0c4f5112a34e21010fa892a0d30579810d3 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Sat, 2 Aug 2025 14:05:22 -0300 Subject: [PATCH 13/16] improve logging --- router/core/logging.py | 22 +++++++++++----------- router/core/main.py | 11 ----------- router/wallet.py | 4 ++-- 3 files changed, 13 insertions(+), 24 deletions(-) diff --git a/router/core/logging.py b/router/core/logging.py index 57c5277a..7f7c0b2c 100644 --- a/router/core/logging.py +++ b/router/core/logging.py @@ -8,6 +8,7 @@ from pathlib import Path from typing import Any from pythonjsonlogger import jsonlogger +from rich.logging import RichHandler class DailyRotatingFileHandler(logging.handlers.TimedRotatingFileHandler): @@ -181,10 +182,6 @@ def setup_logging() -> None: "format": "%(asctime)s %(name)s %(levelname)s %(message)s %(pathname)s %(lineno)d %(version)s", "datefmt": "%Y-%m-%d %H:%M:%S", }, - "standard": { - "format": "%(asctime)s [%(levelname)s] %(name)s v%(version)s: %(message)s", - "datefmt": "%Y-%m-%d %H:%M:%S", - }, }, "filters": { "version_filter": {"()": VersionFilter}, @@ -192,11 +189,13 @@ def setup_logging() -> None: }, "handlers": { "console": { - "class": "logging.StreamHandler", + "()": RichHandler, "level": log_level, - "formatter": "standard", - "stream": "ext://sys.stdout", - "filters": ["version_filter", "security_filter"], + "show_time": False, + "show_path": False, + "rich_tracebacks": True, + "markup": True, + "filters": ["security_filter"], }, "file": { "()": DailyRotatingFileHandler, @@ -252,11 +251,12 @@ def setup_logging() -> None: "handlers": ["console"] if console_enabled else [], "propagate": False, }, - "watchfiles": { - "level": "WARNING", - "handlers": ["console"] if console_enabled else [], + "uvicorn.error": { + "level": "INFO", + "handlers": ["console"], "propagate": False, }, + "watchfiles.main": {"level": "WARNING", "handlers": [], "propagate": False}, }, "root": { "level": log_level, diff --git a/router/core/main.py b/router/core/main.py index 592ccbfd..b2130483 100644 --- a/router/core/main.py +++ b/router/core/main.py @@ -28,17 +28,11 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: try: await init_db() - logger.info("Database initialized successfully") pricing_task = asyncio.create_task(update_sats_pricing()) refund_task = asyncio.create_task(check_for_refunds()) payout_task = asyncio.create_task(periodic_payout()) - logger.info( - "Background tasks started successfully", - extra={"tasks": ["pricing", "refunds", "payouts"]}, - ) - yield except Exception as e: @@ -106,8 +100,3 @@ app.include_router(balance_router) app.include_router(deprecated_wallet_router) app.include_router(providers_router) app.include_router(proxy_router) - -logger.info( - "Application initialized successfully", - extra={"version": __version__, "routers_count": 5}, -) diff --git a/router/wallet.py b/router/wallet.py index bfd3718b..40393671 100644 --- a/router/wallet.py +++ b/router/wallet.py @@ -117,11 +117,11 @@ async def send_to_lnurl(amount: int, unit: CurrencyUnit, lnurl: str) -> dict[str async def check_for_refunds() -> None: - print("check_for_refunds, temp not implemented") + logger.warning("check_for_refunds, temporary not implemented") async def periodic_payout() -> None: - print("periodic_payout, temp not implemented") + logger.warning("periodic_payout, temporary not implemented") # class Proof: From 731bd8d2749a750bc20865d0a6b952e77c137e16 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Sat, 2 Aug 2025 14:25:20 -0300 Subject: [PATCH 14/16] fix "sat" xcashu cost calculation --- router/payment/x_cashu.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/router/payment/x_cashu.py b/router/payment/x_cashu.py index 2c25ec14..68638350 100644 --- a/router/payment/x_cashu.py +++ b/router/payment/x_cashu.py @@ -302,7 +302,13 @@ async def handle_streaming_response( try: cost_data = await get_cost(response_data) if cost_data: - refund_amount = amount - cost_data.total_msats + if unit == "msat": + refund_amount = amount - cost_data.total_msats + elif unit == "sat": + refund_amount = amount - (cost_data.total_msats + 999) // 1000 + else: + raise ValueError(f"Invalid unit: {unit}") + if refund_amount > 0: logger.info( "Processing refund for streaming response", @@ -404,7 +410,12 @@ async def handle_non_streaming_response( if "content-encoding" in response_headers: del response_headers["content-encoding"] - refund_amount = amount - cost_data.total_msats + if unit == "msat": + refund_amount = amount - cost_data.total_msats + elif unit == "sat": + refund_amount = amount - (cost_data.total_msats + 999) // 1000 + else: + raise ValueError(f"Invalid unit: {unit}") logger.info( "Processing non-streaming response cost calculation", From b4e919ebbaa334c4590b0d3fc88cd5e041324629 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Sat, 2 Aug 2025 14:25:29 -0300 Subject: [PATCH 15/16] better error responses --- router/payment/x_cashu.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/router/payment/x_cashu.py b/router/payment/x_cashu.py index 68638350..591ae837 100644 --- a/router/payment/x_cashu.py +++ b/router/payment/x_cashu.py @@ -51,16 +51,37 @@ async def x_cashu_handler( return await forward_to_upstream(request, path, headers, amount, unit) except Exception as e: + error_message = str(e) logger.error( "X-Cashu payment request failed", extra={ - "error": str(e), + "error": error_message, "error_type": type(e).__name__, "path": path, "method": request.method, }, ) - raise + + # Handle specific CASHU errors with appropriate HTTP status codes + if "already spent" in error_message.lower(): + return create_error_response( + "token_already_spent", + "The provided CASHU token has already been spent", + 400, + ) + elif "invalid token" in error_message.lower(): + return create_error_response( + "invalid_token", "The provided CASHU token is invalid", 400 + ) + elif "mint error" in error_message.lower(): + return create_error_response( + "mint_error", f"CASHU mint error: {error_message}", 422 + ) + else: + # Generic error for other cases + return create_error_response( + "cashu_error", f"CASHU token processing failed: {error_message}", 400 + ) async def forward_to_upstream( From 1cb88603ec4a709532d9156bea6692e1e6048274 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Sat, 2 Aug 2025 14:58:06 -0300 Subject: [PATCH 16/16] raise if mint not supported --- router/wallet.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/router/wallet.py b/router/wallet.py index 40393671..b863a1c3 100644 --- a/router/wallet.py +++ b/router/wallet.py @@ -39,8 +39,11 @@ async def recieve_token( unit=token_obj.unit, ) - if token_obj.mint not in TRUSTED_MINTS: - return await swap_to_primary_mint(token_obj, wallet) + if token_obj.mint != PRIMARY_MINT_URL: + raise ValueError( + f"This mint is not supported, please use {PRIMARY_MINT_URL} instead" + ) + await receive(wallet, token_obj) return token_obj.amount, token_obj.unit, token_obj.mint @@ -61,7 +64,14 @@ async def send_token( async def swap_to_primary_mint( token_obj: Token, token_wallet: Wallet ) -> tuple[int, CurrencyUnit, str]: - print(f"swap_to_primary_mint, token_obj: {token_obj}") + logger.info( + "swap_to_primary_mint", + extra={ + "mint": token_obj.mint, + "amount": token_obj.amount, + "unit": token_obj.unit, + }, + ) if token_obj.unit == "sat": amount_msat = token_obj.amount * 1000 elif token_obj.unit == "msat":