From 8a373276ce932cf80df44cbe5dbf369eb77f1b63 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Wed, 11 Mar 2026 22:03:05 +0100 Subject: [PATCH 01/21] cache x-cashu tokens --- .../a776ca70e5fe_add_cashu_refunds_table.py | 34 ++++ routstr/balance.py | 23 ++- routstr/core/db.py | 46 +++++ routstr/core/main.py | 8 +- routstr/core/settings.py | 1 + routstr/upstream/base.py | 157 +++++++++++++++--- routstr/wallet.py | 57 ++++++- 7 files changed, 296 insertions(+), 30 deletions(-) create mode 100644 migrations/versions/a776ca70e5fe_add_cashu_refunds_table.py diff --git a/migrations/versions/a776ca70e5fe_add_cashu_refunds_table.py b/migrations/versions/a776ca70e5fe_add_cashu_refunds_table.py new file mode 100644 index 00000000..5b595cb0 --- /dev/null +++ b/migrations/versions/a776ca70e5fe_add_cashu_refunds_table.py @@ -0,0 +1,34 @@ +"""add cashu_refunds table + +Revision ID: a776ca70e5fe +Revises: 614c0a740e68 +Create Date: 2026-03-11 22:00:01.554762 +""" + +import sqlalchemy as sa +import sqlmodel +from alembic import op + +# revision identifiers, used by Alembic. +revision = 'a776ca70e5fe' +down_revision = '614c0a740e68' +branch_labels = None +depends_on = None + +def upgrade() -> None: + op.create_table( + 'cashu_refunds', + sa.Column('payment_token_hash', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('refund_token', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('amount', sa.Integer(), nullable=False), + sa.Column('unit', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('mint_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('created_at', sa.Integer(), nullable=False), + sa.Column('collected', sa.Boolean(), nullable=False), + sa.Column('swept', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('payment_token_hash'), + ) + + +def downgrade() -> None: + op.drop_table('cashu_refunds') diff --git a/routstr/balance.py b/routstr/balance.py index b738a6c3..fa8c580f 100644 --- a/routstr/balance.py +++ b/routstr/balance.py @@ -9,7 +9,7 @@ from pydantic import BaseModel from sqlmodel import select from .auth import get_billing_key, validate_bearer_key -from .core.db import ApiKey, AsyncSession, get_session +from .core.db import ApiKey, AsyncSession, CashuRefund, get_session from .core.logging import get_logger from .core.settings import settings from .lightning import lightning_router @@ -401,6 +401,27 @@ async def reset_child_key_spent( return {"success": True, "message": "Child key balance reset successfully."} +@router.get("/cashu-refund/{payment_token_hash}") +async def get_cashu_refund( + payment_token_hash: str, + session: AsyncSession = Depends(get_session), +) -> dict: + """Retrieve a stored Cashu refund token by the hash of the original payment token.""" + result = await session.get(CashuRefund, payment_token_hash) + if result is None: + raise HTTPException(status_code=404, detail="Refund not found") + if result.swept: + raise HTTPException(status_code=410, detail="Refund has been swept") + result.collected = True + session.add(result) + await session.commit() + return { + "refund_token": result.refund_token, + "amount": result.amount, + "unit": result.unit, + } + + @router.api_route( "/{path:path}", methods=["GET", "POST", "PUT", "DELETE"], diff --git a/routstr/core/db.py b/routstr/core/db.py index 549ac1f6..556d5c5e 100644 --- a/routstr/core/db.py +++ b/routstr/core/db.py @@ -128,6 +128,52 @@ class LightningInvoice(SQLModel, table=True): # type: ignore paid_at: int | None = Field(default=None, description="Unix timestamp when paid") +class CashuRefund(SQLModel, table=True): # type: ignore + __tablename__ = "cashu_refunds" + + payment_token_hash: str = Field( + primary_key=True, + description="SHA-256 hash of the original x-cashu payment token", + ) + refund_token: str = Field(description="Serialized Cashu refund token") + amount: int = Field(description="Refund amount in the token's unit") + unit: str = Field(description="Token unit (sat or msat)") + mint_url: str | None = Field( + default=None, description="Mint URL for the refund token" + ) + created_at: int = Field( + default_factory=lambda: int(time.time()), + description="Unix timestamp", + ) + collected: bool = Field(default=False) + swept: bool = Field(default=False) + + +async def store_cashu_refund( + payment_token_hash: str, + refund_token: str, + amount: int, + unit: str, + mint_url: str | None = None, +) -> None: + try: + async with create_session() as session: + refund = CashuRefund( + payment_token_hash=payment_token_hash, + refund_token=refund_token, + amount=amount, + unit=unit, + mint_url=mint_url, + ) + session.add(refund) + await session.commit() + except Exception as e: + logger.warning( + "Failed to store cashu refund", + extra={"error": str(e), "payment_token_hash": payment_token_hash}, + ) + + class UpstreamProviderRow(SQLModel, table=True): # type: ignore __tablename__ = "upstream_providers" __table_args__ = ( diff --git a/routstr/core/main.py b/routstr/core/main.py index 461f8b6d..b4b6ac8a 100644 --- a/routstr/core/main.py +++ b/routstr/core/main.py @@ -18,7 +18,7 @@ from ..payment.models import models_router, update_sats_pricing from ..payment.price import update_prices_periodically from ..proxy import initialize_upstreams, proxy_router, refresh_model_maps_periodically from ..upstream.auto_topup import periodic_auto_topup -from ..wallet import periodic_payout +from ..wallet import periodic_payout, periodic_refund_sweep from .admin import admin_router from .db import create_session, init_db, run_migrations from .exceptions import general_exception_handler, http_exception_handler @@ -50,6 +50,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: model_maps_refresh_task = None key_reset_task = None auto_topup_task = None + refund_sweep_task = None try: # Run database migrations on startup @@ -107,6 +108,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: providers_task = asyncio.create_task(providers_cache_refresher()) key_reset_task = asyncio.create_task(periodic_key_reset()) auto_topup_task = asyncio.create_task(periodic_auto_topup()) + refund_sweep_task = asyncio.create_task(periodic_refund_sweep()) yield @@ -140,6 +142,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: key_reset_task.cancel() if auto_topup_task is not None: auto_topup_task.cancel() + if refund_sweep_task is not None: + refund_sweep_task.cancel() try: tasks_to_wait = [] @@ -161,6 +165,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: tasks_to_wait.append(key_reset_task) if auto_topup_task is not None: tasks_to_wait.append(auto_topup_task) + if refund_sweep_task is not None: + tasks_to_wait.append(refund_sweep_task) if tasks_to_wait: await asyncio.gather(*tasks_to_wait, return_exceptions=True) diff --git a/routstr/core/settings.py b/routstr/core/settings.py index fa1464ac..f52f8b04 100644 --- a/routstr/core/settings.py +++ b/routstr/core/settings.py @@ -74,6 +74,7 @@ class Settings(BaseSettings): enable_pricing_refresh: bool = Field(default=True, env="ENABLE_PRICING_REFRESH") enable_models_refresh: bool = Field(default=True, env="ENABLE_MODELS_REFRESH") refund_cache_ttl_seconds: int = Field(default=3600, env="REFUND_CACHE_TTL_SECONDS") + refund_sweep_ttl_seconds: int = Field(default=86400, env="REFUND_SWEEP_TTL_SECONDS") # Logging log_level: str = Field(default="INFO", env="LOG_LEVEL") diff --git a/routstr/upstream/base.py b/routstr/upstream/base.py index 9b66a480..faa89871 100644 --- a/routstr/upstream/base.py +++ b/routstr/upstream/base.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import hashlib import json import re import traceback @@ -15,7 +16,13 @@ from sqlmodel import select from ..auth import adjust_payment_for_tokens from ..core import get_logger -from ..core.db import ApiKey, AsyncSession, UpstreamProviderRow, create_session +from ..core.db import ( + ApiKey, + AsyncSession, + UpstreamProviderRow, + create_session, + store_cashu_refund, +) from ..core.exceptions import UpstreamError from ..payment.cost_calculation import ( CostData, @@ -203,9 +210,7 @@ class BaseUpstreamProvider: return path.replace("v1/", "", 1) return path - def get_request_base_url( - self, path: str, model_obj: Model | None = None - ) -> str: + def get_request_base_url(self, path: str, model_obj: Model | None = None) -> str: """Get upstream base URL used when building forwarding URL.""" return self.base_url.rstrip("/") @@ -1325,9 +1330,7 @@ class BaseUpstreamProvider: ) # Don't revert here — proxy.py owns payment revert to avoid double-revert - raise UpstreamError( - "An unexpected server error occurred", status_code=500 - ) + raise UpstreamError("An unexpected server error occurred", status_code=500) async def forward_responses_request( self, @@ -1539,9 +1542,7 @@ class BaseUpstreamProvider: ) # Don't revert here — proxy.py owns payment revert to avoid double-revert - raise UpstreamError( - "An unexpected server error occurred", status_code=500 - ) + raise UpstreamError("An unexpected server error occurred", status_code=500) async def forward_get_request( self, @@ -1679,13 +1680,20 @@ class BaseUpstreamProvider: ) return None - async def send_refund(self, amount: int, unit: str, mint: str | None = None) -> str: + async def send_refund( + self, + amount: int, + unit: str, + mint: str | None = None, + payment_token_hash: str | None = None, + ) -> str: """Create and send a refund token to the user. Args: amount: Refund amount unit: Unit of the refund (sat or msat) mint: Optional mint URL for the refund token + payment_token_hash: Optional SHA-256 hash of the original payment token for storage Returns: Refund token string @@ -1715,6 +1723,18 @@ class BaseUpstreamProvider: }, ) + if payment_token_hash: + try: + await store_cashu_refund( + payment_token_hash=payment_token_hash, + refund_token=refund_token, + amount=amount, + unit=unit, + mint_url=mint, + ) + except Exception: + pass # store_cashu_refund already logs + return refund_token except Exception as e: last_exception = e @@ -1764,6 +1784,7 @@ class BaseUpstreamProvider: unit: str, max_cost_for_model: int, mint: str | None = None, + payment_token_hash: str | None = None, ) -> StreamingResponse: """Handle streaming response for X-Cashu payment, calculating refund if needed. @@ -1773,6 +1794,7 @@ class BaseUpstreamProvider: amount: Payment amount received unit: Payment unit (sat or msat) max_cost_for_model: Maximum cost for the model + payment_token_hash: Optional hash of original payment token for refund storage Returns: StreamingResponse with refund token in header if applicable @@ -1844,7 +1866,9 @@ class BaseUpstreamProvider: }, ) - refund_token = await self.send_refund(refund_amount, unit, mint) + refund_token = await self.send_refund( + refund_amount, unit, mint, payment_token_hash + ) response_headers["X-Cashu"] = refund_token logger.info( @@ -1897,6 +1921,7 @@ class BaseUpstreamProvider: unit: str, max_cost_for_model: int, mint: str | None = None, + payment_token_hash: str | None = None, ) -> Response: """Handle non-streaming response for X-Cashu payment, calculating refund if needed. @@ -1906,6 +1931,7 @@ class BaseUpstreamProvider: amount: Payment amount received unit: Payment unit (sat or msat) max_cost_for_model: Maximum cost for the model + payment_token_hash: Optional hash of original payment token for refund storage Returns: Response with refund token in header if applicable @@ -1967,7 +1993,9 @@ class BaseUpstreamProvider: ) if refund_amount > 0: - refund_token = await self.send_refund(refund_amount, unit, mint) + refund_token = await self.send_refund( + refund_amount, unit, mint, payment_token_hash + ) response_headers["X-Cashu"] = refund_token logger.info( @@ -2003,6 +2031,13 @@ class BaseUpstreamProvider: emergency_refund = amount refund_token = await send_token(emergency_refund, unit=unit, mint_url=mint) response.headers["X-Cashu"] = refund_token + if payment_token_hash: + try: + await store_cashu_refund( + payment_token_hash, refund_token, emergency_refund, unit, mint + ) + except Exception: + pass logger.warning( "Emergency refund issued due to JSON parse error", @@ -2027,6 +2062,7 @@ class BaseUpstreamProvider: unit: str, max_cost_for_model: int, mint: str | None = None, + payment_token_hash: str | None = None, ) -> StreamingResponse | Response: """Handle chat completion response for X-Cashu payment, detecting streaming vs non-streaming. @@ -2063,11 +2099,23 @@ class BaseUpstreamProvider: if is_streaming: return await self.handle_x_cashu_streaming_response( - content_str, response, amount, unit, max_cost_for_model, mint + content_str, + response, + amount, + unit, + max_cost_for_model, + mint, + payment_token_hash, ) else: return await self.handle_x_cashu_non_streaming_response( - content_str, response, amount, unit, max_cost_for_model, mint + content_str, + response, + amount, + unit, + max_cost_for_model, + mint, + payment_token_hash, ) except Exception as e: @@ -2096,6 +2144,7 @@ class BaseUpstreamProvider: max_cost_for_model: int, model_obj: Model, mint: str | None = None, + payment_token_hash: str | None = None, ) -> Response | StreamingResponse: """Forward request paid with X-Cashu token to upstream service. @@ -2166,7 +2215,9 @@ class BaseUpstreamProvider: }, ) - refund_token = await self.send_refund(amount - 60, unit, mint) + refund_token = await self.send_refund( + amount - 60, unit, mint, payment_token_hash + ) logger.info( "Refund processed for failed upstream request", @@ -2204,7 +2255,12 @@ class BaseUpstreamProvider: ) result = await self.handle_x_cashu_chat_completion( - response, amount, unit, max_cost_for_model, mint + response, + amount, + unit, + max_cost_for_model, + mint, + payment_token_hash, ) background_tasks = BackgroundTasks() background_tasks.add_task(response.aclose) @@ -2279,6 +2335,7 @@ class BaseUpstreamProvider: ) try: + payment_token_hash = hashlib.sha256(x_cashu_token.encode()).hexdigest() headers = dict(request.headers) amount, unit, mint = await recieve_token(x_cashu_token) headers = self.prepare_headers(dict(request.headers)) @@ -2297,6 +2354,7 @@ class BaseUpstreamProvider: max_cost_for_model, model_obj, mint, + payment_token_hash, ) except Exception as e: error_message = str(e) @@ -2356,6 +2414,7 @@ class BaseUpstreamProvider: max_cost_for_model: int, model_obj: Model, mint: str | None = None, + payment_token_hash: str | None = None, ) -> Response | StreamingResponse: """Forward Responses API request paid with X-Cashu token to upstream service. @@ -2427,7 +2486,9 @@ class BaseUpstreamProvider: }, ) - refund_token = await self.send_refund(amount - 60, unit, mint) + refund_token = await self.send_refund( + amount - 60, unit, mint, payment_token_hash + ) logger.info( "Refund processed for failed upstream Responses API request", @@ -2465,7 +2526,12 @@ class BaseUpstreamProvider: ) result = await self.handle_x_cashu_responses_completion( - response, amount, unit, max_cost_for_model, mint + response, + amount, + unit, + max_cost_for_model, + mint, + payment_token_hash, ) background_tasks = BackgroundTasks() background_tasks.add_task(response.aclose) @@ -2515,6 +2581,7 @@ class BaseUpstreamProvider: unit: str, max_cost_for_model: int, mint: str | None = None, + payment_token_hash: str | None = None, ) -> StreamingResponse | Response: """Handle Responses API completion response for X-Cashu payment. @@ -2552,11 +2619,23 @@ class BaseUpstreamProvider: if is_streaming: return await self.handle_x_cashu_streaming_responses_response( - content_str, response, amount, unit, max_cost_for_model, mint + content_str, + response, + amount, + unit, + max_cost_for_model, + mint, + payment_token_hash, ) else: return await self.handle_x_cashu_non_streaming_responses_response( - content_str, response, amount, unit, max_cost_for_model, mint + content_str, + response, + amount, + unit, + max_cost_for_model, + mint, + payment_token_hash, ) except Exception as e: @@ -2583,6 +2662,7 @@ class BaseUpstreamProvider: unit: str, max_cost_for_model: int, mint: str | None = None, + payment_token_hash: str | None = None, ) -> StreamingResponse: """Handle streaming Responses API response for X-Cashu payment. @@ -2664,7 +2744,9 @@ class BaseUpstreamProvider: }, ) - refund_token = await self.send_refund(refund_amount, unit, mint) + refund_token = await self.send_refund( + refund_amount, unit, mint, payment_token_hash + ) response_headers["X-Cashu"] = refund_token logger.info( @@ -2717,6 +2799,7 @@ class BaseUpstreamProvider: unit: str, max_cost_for_model: int, mint: str | None = None, + payment_token_hash: str | None = None, ) -> Response: """Handle non-streaming Responses API response for X-Cashu payment.""" logger.debug( @@ -2776,7 +2859,9 @@ class BaseUpstreamProvider: ) if refund_amount > 0: - refund_token = await self.send_refund(refund_amount, unit, mint) + refund_token = await self.send_refund( + refund_amount, unit, mint, payment_token_hash + ) response_headers["X-Cashu"] = refund_token logger.info( @@ -2812,6 +2897,13 @@ class BaseUpstreamProvider: emergency_refund = amount refund_token = await send_token(emergency_refund, unit=unit, mint_url=mint) response.headers["X-Cashu"] = refund_token + if payment_token_hash: + try: + await store_cashu_refund( + payment_token_hash, refund_token, emergency_refund, unit, mint + ) + except Exception: + pass logger.warning( "Emergency refund issued for Responses API due to JSON parse error", @@ -2861,6 +2953,7 @@ class BaseUpstreamProvider: ) try: + payment_token_hash = hashlib.sha256(x_cashu_token.encode()).hexdigest() headers = dict(request.headers) amount, unit, mint = await recieve_token(x_cashu_token) headers = self.prepare_headers(dict(request.headers)) @@ -2879,6 +2972,7 @@ class BaseUpstreamProvider: max_cost_for_model, model_obj, mint, + payment_token_hash, ) except Exception as e: error_message = str(e) @@ -3090,7 +3184,7 @@ class BaseUpstreamProvider: async with create_session() as session: stmt = select(UpstreamProviderRow).where( UpstreamProviderRow.base_url == self.base_url, - UpstreamProviderRow.api_key == self.api_key + UpstreamProviderRow.api_key == self.api_key, ) result = await session.exec(stmt) @@ -3111,15 +3205,24 @@ class BaseUpstreamProvider: diff = set(db_model_ids) - set(model_ids) for db_model_id in diff: - found_db_model = next((model_obj for model_obj in db_models if model_obj.id == db_model_id)) + found_db_model = next( + ( + model_obj + for model_obj in db_models + if model_obj.id == db_model_id + ) + ) models.append(found_db_model) - models_with_fees = [self._apply_provider_fee_to_model(m) for m in models] + models_with_fees = [ + self._apply_provider_fee_to_model(m) for m in models + ] try: sats_to_usd = sats_usd_price() self._models_cache = [ - _update_model_sats_pricing(m, sats_to_usd) for m in models_with_fees + _update_model_sats_pricing(m, sats_to_usd) + for m in models_with_fees ] except Exception: self._models_cache = models_with_fees diff --git a/routstr/wallet.py b/routstr/wallet.py index 71ea18ac..f7bbcd73 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -1,11 +1,12 @@ import asyncio import math +import time from typing import TypedDict from cashu.core.base import Proof, Token from cashu.wallet.helpers import deserialize_token_from_string from cashu.wallet.wallet import Wallet -from sqlmodel import col, update +from sqlmodel import col, select, update from .core import db, get_logger from .core.settings import settings @@ -352,6 +353,60 @@ async def periodic_payout() -> None: ) +async def periodic_refund_sweep() -> None: + while True: + await asyncio.sleep(60 * 60) # every hour + try: + cutoff = int(time.time()) - settings.refund_sweep_ttl_seconds + async with db.create_session() as session: + stmt = select(db.CashuRefund).where( + db.CashuRefund.collected == False, # noqa: E712 + db.CashuRefund.swept == False, # noqa: E712 + db.CashuRefund.created_at < cutoff, + ) + results = await session.exec(stmt) + refunds = results.all() + + for refund in refunds: + try: + await recieve_token(refund.refund_token) + refund.swept = True + session.add(refund) + logger.info( + "Swept uncollected refund", + extra={ + "payment_token_hash": refund.payment_token_hash, + "amount": refund.amount, + "unit": refund.unit, + }, + ) + except Exception as e: + error_msg = str(e).lower() + if "already spent" in error_msg: + refund.swept = True + session.add(refund) + logger.info( + "Refund already spent (client collected), marking swept", + extra={ + "payment_token_hash": refund.payment_token_hash, + }, + ) + else: + logger.warning( + "Failed to sweep refund", + extra={ + "payment_token_hash": refund.payment_token_hash, + "error": str(e), + }, + ) + await session.commit() + except Exception as e: + logger.error( + "Error in periodic refund sweep", + extra={"error": str(e), "error_type": type(e).__name__}, + ) + + async def send_to_lnurl(amount: int, unit: str, mint: str, address: str) -> int: wallet = await get_wallet(mint, unit) proofs = wallet._get_proofs_per_keyset(wallet.proofs)[wallet.keyset_id] From 2c2124952f6f7d7d2301c1b1b36d1cea8f60353f Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Wed, 11 Mar 2026 23:39:22 +0100 Subject: [PATCH 02/21] add transaction view --- .../a776ca70e5fe_add_cashu_refunds_table.py | 36 ++- routstr/balance.py | 6 +- routstr/core/admin.py | 57 +++- routstr/core/db.py | 67 ++-- routstr/core/settings.py | 7 +- routstr/upstream/base.py | 29 +- routstr/wallet.py | 33 +- ui/app/transactions/page.tsx | 285 ++++++++++++++++++ ui/components/app-page-shell.tsx | 2 + ui/components/app-sidebar.tsx | 6 + ui/lib/api/services/admin.ts | 35 +++ 11 files changed, 503 insertions(+), 60 deletions(-) create mode 100644 ui/app/transactions/page.tsx diff --git a/migrations/versions/a776ca70e5fe_add_cashu_refunds_table.py b/migrations/versions/a776ca70e5fe_add_cashu_refunds_table.py index 5b595cb0..6342f25c 100644 --- a/migrations/versions/a776ca70e5fe_add_cashu_refunds_table.py +++ b/migrations/versions/a776ca70e5fe_add_cashu_refunds_table.py @@ -1,4 +1,4 @@ -"""add cashu_refunds table +"""add cashu_transactions table Revision ID: a776ca70e5fe Revises: 614c0a740e68 @@ -10,25 +10,33 @@ import sqlmodel from alembic import op # revision identifiers, used by Alembic. -revision = 'a776ca70e5fe' -down_revision = '614c0a740e68' +revision = "a776ca70e5fe" +down_revision = "614c0a740e68" branch_labels = None depends_on = None + def upgrade() -> None: op.create_table( - 'cashu_refunds', - sa.Column('payment_token_hash', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('refund_token', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('amount', sa.Integer(), nullable=False), - sa.Column('unit', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('mint_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('created_at', sa.Integer(), nullable=False), - sa.Column('collected', sa.Boolean(), nullable=False), - sa.Column('swept', sa.Boolean(), nullable=False), - sa.PrimaryKeyConstraint('payment_token_hash'), + "cashu_transactions", + sa.Column("id", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("token", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("amount", sa.Integer(), nullable=False), + sa.Column("unit", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("mint_url", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column( + "type", + sqlmodel.sql.sqltypes.AutoString(), + nullable=False, + server_default="out", + ), + sa.Column("request_id", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("created_at", sa.Integer(), nullable=False), + sa.Column("collected", sa.Boolean(), nullable=False), + sa.Column("swept", sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint("id"), ) def downgrade() -> None: - op.drop_table('cashu_refunds') + op.drop_table("cashu_transactions") diff --git a/routstr/balance.py b/routstr/balance.py index fa8c580f..7edede9b 100644 --- a/routstr/balance.py +++ b/routstr/balance.py @@ -9,7 +9,7 @@ from pydantic import BaseModel from sqlmodel import select from .auth import get_billing_key, validate_bearer_key -from .core.db import ApiKey, AsyncSession, CashuRefund, get_session +from .core.db import ApiKey, AsyncSession, CashuTransaction, get_session from .core.logging import get_logger from .core.settings import settings from .lightning import lightning_router @@ -407,7 +407,7 @@ async def get_cashu_refund( session: AsyncSession = Depends(get_session), ) -> dict: """Retrieve a stored Cashu refund token by the hash of the original payment token.""" - result = await session.get(CashuRefund, payment_token_hash) + result = await session.get(CashuTransaction, payment_token_hash) if result is None: raise HTTPException(status_code=404, detail="Refund not found") if result.swept: @@ -416,7 +416,7 @@ async def get_cashu_refund( session.add(result) await session.commit() return { - "refund_token": result.refund_token, + "refund_token": result.token, "amount": result.amount, "unit": result.unit, } diff --git a/routstr/core/admin.py b/routstr/core/admin.py index c3160c9e..6884a959 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -16,7 +16,7 @@ from ..wallet import ( send_token, slow_filter_spend_proofs, ) -from .db import ApiKey, ModelRow, UpstreamProviderRow, create_session +from .db import ApiKey, CashuTransaction, ModelRow, UpstreamProviderRow, create_session from .log_manager import log_manager from .logging import get_logger from .settings import SettingsService, settings @@ -747,7 +747,9 @@ async def get_provider_models(provider_id: int) -> dict[str, object]: ) db_model_ids = {model.id for model in db_models} - filtered_remote_models = [m for m in upstream_models if m.id not in db_model_ids] + filtered_remote_models = [ + m for m in upstream_models if m.id not in db_model_ids + ] return { "provider": { @@ -893,7 +895,9 @@ async def initiate_provider_topup( "purpose": "topup", "api_key": provider.api_key, }, - headers={"Authorization": f"Bearer {provider.api_key}"} if provider.api_key else {}, + headers={"Authorization": f"Bearer {provider.api_key}"} + if provider.api_key + else {}, ) if resp.status_code == 200: @@ -977,7 +981,9 @@ async def check_topup_status(provider_id: int, invoice_id: str) -> dict[str, obj clean_url = provider.base_url.rstrip("/") resp = await client.get( f"{clean_url}/v1/balance/lightning/invoice/{invoice_id}/status", - headers={"Authorization": f"Bearer {provider.api_key}"} if provider.api_key else {}, + headers={"Authorization": f"Bearer {provider.api_key}"} + if provider.api_key + else {}, ) if resp.status_code == 200: status_data = resp.json() @@ -1208,6 +1214,49 @@ async def get_log_dates_api(request: Request) -> dict[str, object]: return {"dates": dates} +@admin_router.get("/api/transactions", dependencies=[Depends(require_admin_api)]) +async def get_transactions_api( + type: str | None = None, + status: str | None = None, + search: str | None = None, + limit: int = 100, +) -> dict: + async with create_session() as session: + from sqlmodel import col + + stmt = select(CashuTransaction) + if type: + stmt = stmt.where(CashuTransaction.type == type) + if status: + if status == "collected": + stmt = stmt.where(CashuTransaction.collected == True) # noqa: E712 + elif status == "swept": + stmt = stmt.where(CashuTransaction.swept == True) # noqa: E712 + elif status == "pending": + stmt = stmt.where( + not CashuTransaction.collected, + CashuTransaction.swept == False, # noqa: E712 + ) + + if search: + search_pattern = f"%{search}%" + stmt = stmt.where( + (col(CashuTransaction.id).like(search_pattern)) + | (col(CashuTransaction.token).like(search_pattern)) + | (col(CashuTransaction.request_id).like(search_pattern)) + ) + + stmt = stmt.order_by(col(CashuTransaction.created_at).desc()).limit(limit) + + results = await session.exec(stmt) + transactions = results.all() + + return { + "transactions": [tx.dict() for tx in transactions], + "total": len(transactions), + } + + @admin_router.post( "/api/upstream-providers/{provider_id}/routstr/refund", dependencies=[Depends(require_admin_api)], diff --git a/routstr/core/db.py b/routstr/core/db.py index 556d5c5e..50f935b8 100644 --- a/routstr/core/db.py +++ b/routstr/core/db.py @@ -7,6 +7,7 @@ from typing import AsyncGenerator from alembic import command from alembic.config import Config +from alembic.util.exc import CommandError from sqlalchemy import UniqueConstraint from sqlalchemy.ext.asyncio.engine import create_async_engine from sqlmodel import Field, Relationship, SQLModel, func, select, update @@ -128,19 +129,19 @@ class LightningInvoice(SQLModel, table=True): # type: ignore paid_at: int | None = Field(default=None, description="Unix timestamp when paid") -class CashuRefund(SQLModel, table=True): # type: ignore - __tablename__ = "cashu_refunds" +class CashuTransaction(SQLModel, table=True): # type: ignore + __tablename__ = "cashu_transactions" - payment_token_hash: str = Field( + id: str = Field( primary_key=True, - description="SHA-256 hash of the original x-cashu payment token", + description="SHA-256 hash of the x-cashu token or unique identifier", ) - refund_token: str = Field(description="Serialized Cashu refund token") - amount: int = Field(description="Refund amount in the token's unit") + token: str = Field(description="Serialized Cashu token") + amount: int = Field(description="Amount in the token's unit") unit: str = Field(description="Token unit (sat or msat)") - mint_url: str | None = Field( - default=None, description="Mint URL for the refund token" - ) + mint_url: str | None = Field(default=None, description="Mint URL for the token") + type: str = Field(default="out", description="Transaction type: in or out") + request_id: str | None = Field(default=None, description="Associated request ID") created_at: int = Field( default_factory=lambda: int(time.time()), description="Unix timestamp", @@ -149,28 +150,32 @@ class CashuRefund(SQLModel, table=True): # type: ignore swept: bool = Field(default=False) -async def store_cashu_refund( - payment_token_hash: str, - refund_token: str, +async def store_cashu_transaction( + id: str, + token: str, amount: int, unit: str, mint_url: str | None = None, + type: str = "out", + request_id: str | None = None, ) -> None: try: async with create_session() as session: - refund = CashuRefund( - payment_token_hash=payment_token_hash, - refund_token=refund_token, + tx = CashuTransaction( + id=id, + token=token, amount=amount, unit=unit, mint_url=mint_url, + type=type, + request_id=request_id, ) - session.add(refund) + session.add(tx) await session.commit() except Exception as e: logger.warning( - "Failed to store cashu refund", - extra={"error": str(e), "payment_token_hash": payment_token_hash}, + "Failed to store cashu transaction", + extra={"error": str(e), "id": id, "type": type}, ) @@ -273,6 +278,17 @@ def fix_cashu_migrations() -> None: logger.warning(f"Could not check/fix Cashu database {db_file}: {e}") +def _clear_alembic_version() -> None: + """Clear the alembic_version table so stamp/upgrade can proceed.""" + sync_url = DATABASE_URL.replace("+aiosqlite", "") + from sqlalchemy import create_engine, text + + eng = create_engine(sync_url) + with eng.begin() as conn: + conn.execute(text("DELETE FROM alembic_version")) + eng.dispose() + + def run_migrations() -> None: """Run Alembic migrations programmatically.""" try: @@ -294,8 +310,19 @@ def run_migrations() -> None: # Set the database URL in the config alembic_cfg.set_main_option("sqlalchemy.url", DATABASE_URL) - # Run migrations to the latest revision - command.upgrade(alembic_cfg, "head") + try: + command.upgrade(alembic_cfg, "head") + except CommandError as e: + if "Can't locate revision" in str(e): + logger.warning( + "Database stamped with unknown revision (likely from another branch). " + "Re-stamping to current head.", + extra={"error": str(e)}, + ) + _clear_alembic_version() + command.stamp(alembic_cfg, "head") + else: + raise logger.info("Database migrations completed successfully") diff --git a/routstr/core/settings.py b/routstr/core/settings.py index f52f8b04..fba0383c 100644 --- a/routstr/core/settings.py +++ b/routstr/core/settings.py @@ -238,9 +238,10 @@ class SettingsService: except Exception: db_json = {} + valid_fields = set(env_resolved.dict().keys()) merged_dict: dict[str, Any] = dict(env_resolved.dict()) merged_dict.update( - {k: v for k, v in db_json.items() if v not in (None, "", [], {})} + {k: v for k, v in db_json.items() if v not in (None, "", [], {}) and k in valid_fields} ) # Ensure primary_mint is consistent with cashu_mints if not explicitly set @@ -306,8 +307,10 @@ class SettingsService: raise RuntimeError("Settings row missing") (data_str,) = row data = json.loads(data_str) if isinstance(data_str, str) else dict(data_str) + valid_fields = set(settings.dict().keys()) # Update in-place for k, v in data.items(): - setattr(settings, k, v) + if k in valid_fields: + setattr(settings, k, v) cls._current = settings return settings diff --git a/routstr/upstream/base.py b/routstr/upstream/base.py index faa89871..cf2a2fe3 100644 --- a/routstr/upstream/base.py +++ b/routstr/upstream/base.py @@ -21,7 +21,7 @@ from ..core.db import ( AsyncSession, UpstreamProviderRow, create_session, - store_cashu_refund, + store_cashu_transaction, ) from ..core.exceptions import UpstreamError from ..payment.cost_calculation import ( @@ -1725,15 +1725,16 @@ class BaseUpstreamProvider: if payment_token_hash: try: - await store_cashu_refund( - payment_token_hash=payment_token_hash, - refund_token=refund_token, + await store_cashu_transaction( + id=payment_token_hash, + token=refund_token, amount=amount, unit=unit, mint_url=mint, + type="out", ) except Exception: - pass # store_cashu_refund already logs + pass # store_cashu_transaction already logs return refund_token except Exception as e: @@ -2033,8 +2034,13 @@ class BaseUpstreamProvider: response.headers["X-Cashu"] = refund_token if payment_token_hash: try: - await store_cashu_refund( - payment_token_hash, refund_token, emergency_refund, unit, mint + await store_cashu_transaction( + id=payment_token_hash, + token=refund_token, + amount=emergency_refund, + unit=unit, + mint_url=mint, + type="out", ) except Exception: pass @@ -2899,8 +2905,13 @@ class BaseUpstreamProvider: response.headers["X-Cashu"] = refund_token if payment_token_hash: try: - await store_cashu_refund( - payment_token_hash, refund_token, emergency_refund, unit, mint + await store_cashu_transaction( + id=payment_token_hash, + token=refund_token, + amount=emergency_refund, + unit=unit, + mint_url=mint, + type="out", ) except Exception: pass diff --git a/routstr/wallet.py b/routstr/wallet.py index f7bbcd73..6bdcf0e5 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -1,4 +1,5 @@ import asyncio +import hashlib import math import time from typing import TypedDict @@ -35,6 +36,21 @@ async def recieve_token( wallet.verify_proofs_dleq(token_obj.proofs) await wallet.split(proofs=token_obj.proofs, amount=0, include_fees=True) + + # Store incoming transaction + try: + token_hash = hashlib.sha256(token.encode()).hexdigest() + await db.store_cashu_transaction( + id=token_hash, + token=token, + amount=token_obj.amount, + unit=token_obj.unit, + mint_url=token_obj.mint, + type="in", + ) + except Exception: + pass + return token_obj.amount, token_obj.unit, token_obj.mint @@ -359,23 +375,24 @@ async def periodic_refund_sweep() -> None: try: cutoff = int(time.time()) - settings.refund_sweep_ttl_seconds async with db.create_session() as session: - stmt = select(db.CashuRefund).where( - db.CashuRefund.collected == False, # noqa: E712 - db.CashuRefund.swept == False, # noqa: E712 - db.CashuRefund.created_at < cutoff, + stmt = select(db.CashuTransaction).where( + db.CashuTransaction.type == "out", + db.CashuTransaction.collected == False, # noqa: E712 + db.CashuTransaction.swept == False, # noqa: E712 + db.CashuTransaction.created_at < cutoff, ) results = await session.exec(stmt) refunds = results.all() for refund in refunds: try: - await recieve_token(refund.refund_token) + await recieve_token(refund.token) refund.swept = True session.add(refund) logger.info( "Swept uncollected refund", extra={ - "payment_token_hash": refund.payment_token_hash, + "id": refund.id, "amount": refund.amount, "unit": refund.unit, }, @@ -388,14 +405,14 @@ async def periodic_refund_sweep() -> None: logger.info( "Refund already spent (client collected), marking swept", extra={ - "payment_token_hash": refund.payment_token_hash, + "id": refund.id, }, ) else: logger.warning( "Failed to sweep refund", extra={ - "payment_token_hash": refund.payment_token_hash, + "id": refund.id, "error": str(e), }, ) diff --git a/ui/app/transactions/page.tsx b/ui/app/transactions/page.tsx new file mode 100644 index 00000000..256cf7d6 --- /dev/null +++ b/ui/app/transactions/page.tsx @@ -0,0 +1,285 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { AppPageShell } from '@/components/app-page-shell'; +import { PageHeader } from '@/components/page-header'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Badge } from '@/components/ui/badge'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + RefreshCw, + Search, + ArrowDownLeft, + ArrowUpRight, + Copy, + Check, +} from 'lucide-react'; +import { AdminService, type Transaction } from '@/lib/api/services/admin'; +import { formatDistanceToNow } from 'date-fns'; +import { toast } from 'sonner'; + +export default function TransactionsPage() { + const [search, setSearch] = useState(''); + const [type, setType] = useState('all'); + const [status, setStatus] = useState('all'); + const [copiedId, setCopiedId] = useState(null); + + const { data, isLoading, refetch, isRefetching } = useQuery({ + queryKey: ['transactions', type, status, search], + queryFn: () => + AdminService.getTransactions( + type === 'all' ? undefined : type, + status === 'all' ? undefined : status, + search || undefined, + 100 + ), + }); + + const copyToClipboard = (text: string, id: string) => { + navigator.clipboard.writeText(text); + setCopiedId(id); + toast.success('Copied to clipboard'); + setTimeout(() => setCopiedId(null), 2000); + }; + + const getStatusBadge = (tx: Transaction) => { + if (tx.swept) + return ( + + Swept + + ); + if (tx.collected) + return ( + + Collected + + ); + return ( + + Pending + + ); + }; + + return ( + +
+ refetch()} + variant='outline' + size='sm' + disabled={isRefetching} + > + + Refresh + + } + /> + +
+
+
+ + setSearch(e.target.value)} + /> +
+
+ + +
+ + + + Transaction History + + + {isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : ( + + + + + Type + Amount + Status + ID / Request ID + Mint + Date + Actions + + + + {data?.transactions.map((tx) => ( + + +
+ {tx.type === 'in' ? ( + + ) : ( + + )} + {tx.type} +
+
+ + {tx.amount} {tx.unit} + + {getStatusBadge(tx)} + +
+
+ + {tx.id} + + +
+ {tx.request_id && ( +
+ + {tx.request_id} + + +
+ )} +
+
+ +
+ {tx.mint_url} +
+
+ + {formatDistanceToNow(tx.created_at * 1000, { + addSuffix: true, + })} + + + + +
+ ))} + {data?.transactions.length === 0 && ( + + + No transactions found + + + )} +
+
+
+ )} +
+
+
+
+ ); +} diff --git a/ui/components/app-page-shell.tsx b/ui/components/app-page-shell.tsx index ad2fdd31..6b613862 100644 --- a/ui/components/app-page-shell.tsx +++ b/ui/components/app-page-shell.tsx @@ -13,6 +13,7 @@ import { ServerIcon, SettingsIcon, WalletIcon, + ArrowRightLeftIcon, } from 'lucide-react'; import Image from 'next/image'; import { toast } from 'sonner'; @@ -41,6 +42,7 @@ const NAV_ITEMS = [ { title: 'Logs', url: '/logs', icon: FileTextIcon }, { title: 'Models', url: '/models', icon: DatabaseIcon }, { title: 'Providers', url: '/providers', icon: ServerIcon }, + { title: 'Transactions', url: '/transactions', icon: ArrowRightLeftIcon }, { title: 'Settings', url: '/settings', icon: SettingsIcon }, ] as const; diff --git a/ui/components/app-sidebar.tsx b/ui/components/app-sidebar.tsx index 76179f1c..7c777240 100644 --- a/ui/components/app-sidebar.tsx +++ b/ui/components/app-sidebar.tsx @@ -9,6 +9,7 @@ import { ServerIcon, SettingsIcon, WalletIcon, + ArrowRightLeftIcon, } from 'lucide-react'; import Image from 'next/image'; import Link from 'next/link'; @@ -45,6 +46,11 @@ const data = { url: '/balances', icon: WalletIcon, }, + { + title: 'Transactions', + url: '/transactions', + icon: ArrowRightLeftIcon, + }, { title: 'Logs', url: '/logs', diff --git a/ui/lib/api/services/admin.ts b/ui/lib/api/services/admin.ts index d91e24cb..8ed8e3d7 100644 --- a/ui/lib/api/services/admin.ts +++ b/ui/lib/api/services/admin.ts @@ -869,6 +869,23 @@ export class AdminService { ); } + static async getTransactions( + type?: string, + status?: string, + search?: string, + limit: number = 100 + ): Promise { + const params = new URLSearchParams(); + if (type) params.append('type', type); + if (status) params.append('status', status); + if (search) params.append('search', search); + params.append('limit', limit.toString()); + + return await apiClient.get( + `/admin/api/transactions?${params.toString()}` + ); + } + static async createProviderAccountByType(providerType: string): Promise<{ ok: boolean; account_data: Record; @@ -1042,3 +1059,21 @@ export interface LogResponse { search: string | null; limit: number; } + +export interface Transaction { + id: string; + token: string; + amount: number; + unit: string; + mint_url: string; + type: 'in' | 'out'; + request_id?: string; + created_at: number; + collected: boolean; + swept: boolean; +} + +export interface TransactionsResponse { + transactions: Transaction[]; + total: number; +} From deb75624f33d215c58965a8cbe48741fcd30c00b Mon Sep 17 00:00:00 2001 From: Shroominic Date: Fri, 13 Mar 2026 17:36:52 +0800 Subject: [PATCH 03/21] fix: show Routstr provider balances in sats --- ui/components/provider-balance.tsx | 23 +++++++++++++++++++---- ui/components/provider-card.tsx | 2 ++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/ui/components/provider-balance.tsx b/ui/components/provider-balance.tsx index b6460d97..d36041c5 100644 --- a/ui/components/provider-balance.tsx +++ b/ui/components/provider-balance.tsx @@ -23,11 +23,15 @@ import { interface ProviderBalanceProps { providerId: number; platformUrl?: string | null; + isRoutstr?: boolean; + nodeUrl?: string; } export function ProviderBalance({ providerId, platformUrl, + isRoutstr = false, + nodeUrl, }: ProviderBalanceProps) { const [isTopupDialogOpen, setIsTopupDialogOpen] = useState(false); const [topupAmount, setTopupAmount] = useState(''); @@ -153,15 +157,24 @@ export function ProviderBalance({ let displayValue = 'N/A'; if (typeof balance === 'number') { - displayValue = `$${balance.toFixed(2)}`; + displayValue = isRoutstr + ? `${balance.toLocaleString()} sats` + : `$${balance.toFixed(2)}`; } else if (balance && typeof balance === 'object') { const b = balance as Record; if (typeof b.balance === 'number') { - displayValue = `$${b.balance.toFixed(2)}`; + displayValue = isRoutstr + ? `${b.balance.toLocaleString()} sats` + : `$${b.balance.toFixed(2)}`; } else if (typeof b.balance === 'string') { displayValue = b.balance; } else if (b.amount !== undefined) { - displayValue = `$${Number(b.amount).toFixed(2)}`; + const amount = Number(b.amount); + if (!Number.isNaN(amount)) { + displayValue = isRoutstr + ? `${amount.toLocaleString()} sats` + : `$${amount.toFixed(2)}`; + } } } @@ -193,7 +206,9 @@ export function ProviderBalance({ ? 'Your account balance has been updated.' : invoiceData ? 'Scan the QR code or copy the Lightning invoice to pay.' - : 'Enter the amount you want to add to your account balance.'} + : isRoutstr + ? `Top up your balance on node ${nodeUrl || ''}`.trim() + : 'Enter the amount you want to add to your account balance.'} diff --git a/ui/components/provider-card.tsx b/ui/components/provider-card.tsx index d239547e..9c0c99bd 100644 --- a/ui/components/provider-card.tsx +++ b/ui/components/provider-card.tsx @@ -107,6 +107,8 @@ export function ProviderCard({ )} From 7e648cb5c2578a0b188bb48168d61f9d1d9bfdf9 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Fri, 13 Mar 2026 17:46:57 +0800 Subject: [PATCH 04/21] fix: use sats for Routstr top-up amounts --- ui/components/provider-balance.tsx | 36 +++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/ui/components/provider-balance.tsx b/ui/components/provider-balance.tsx index d36041c5..f2da5206 100644 --- a/ui/components/provider-balance.tsx +++ b/ui/components/provider-balance.tsx @@ -104,14 +104,23 @@ export function ProviderBalance({ }); const handleTopup = () => { - const amount = parseFloat(topupAmount); + const amount = Number(topupAmount); - if (isNaN(amount)) { - setTopupError('Please enter a valid amount'); + if (Number.isNaN(amount)) { + setTopupError( + isRoutstr + ? 'Please enter a valid amount in sats' + : 'Please enter a valid amount' + ); return; } - if (amount < 1 || amount > 500) { + if (isRoutstr) { + if (!Number.isInteger(amount) || amount < 1) { + setTopupError('Amount must be a whole number of sats'); + return; + } + } else if (amount < 1 || amount > 500) { setTopupError('Amount must be between $1 and $500'); return; } @@ -269,20 +278,31 @@ export function ProviderBalance({ ) : (
- + { setTopupAmount(e.target.value); setTopupError(''); }} min='1' - max='500' - step='0.01' + max={isRoutstr ? undefined : '500'} + step={isRoutstr ? '1' : '0.01'} /> + {isRoutstr && ( +

+ The invoice amount will be created in sats. +

+ )} {topupError && (

{topupError}

)} From 0198569a9adc8dda6d13d70fa5ade953986388f7 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Fri, 13 Mar 2026 18:17:19 +0800 Subject: [PATCH 05/21] fix: retry transient Routstr top-up invoice failures --- routstr/core/admin.py | 129 ++++++++++-------- tests/integration/test_provider_management.py | 83 +++++++++++ 2 files changed, 153 insertions(+), 59 deletions(-) diff --git a/routstr/core/admin.py b/routstr/core/admin.py index c3160c9e..e15bcce8 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -1,3 +1,4 @@ +import asyncio import json import secrets from datetime import datetime, timezone @@ -865,12 +866,6 @@ async def initiate_provider_topup( if not provider: raise HTTPException(status_code=404, detail="Provider not found") - upstream_instance = _instantiate_provider(provider) - if not upstream_instance: - raise HTTPException( - status_code=400, detail="Could not instantiate provider" - ) - try: logger.info( f"Initiating top-up for provider {provider_id}", @@ -884,39 +879,69 @@ async def initiate_provider_topup( async with httpx.AsyncClient() as client: clean_url = provider.base_url.rstrip("/") - # Proxy the request to upstream Routstr - # Use the actual API key from the database - resp = await client.post( - f"{clean_url}/v1/balance/lightning/invoice", - json={ - "amount_sats": int(payload.amount), - "purpose": "topup", - "api_key": provider.api_key, - }, - headers={"Authorization": f"Bearer {provider.api_key}"} if provider.api_key else {}, + request_json = { + "amount_sats": int(payload.amount), + "purpose": "topup", + "api_key": provider.api_key, + } + headers = ( + {"Authorization": f"Bearer {provider.api_key}"} + if provider.api_key + else {} ) - if resp.status_code == 200: - data = resp.json() - return { - "ok": True, - "topup_data": { - "payment_request": data.get("bolt11"), - "invoice_id": data.get("invoice_id"), - "status": "pending", - }, - } - else: - logger.error(f"Upstream topup request failed: {resp.text}") - # Check if it's JSON error - try: - error_detail = resp.json() - except Exception: - error_detail = resp.text - raise HTTPException( - status_code=resp.status_code, detail=error_detail + last_status_code = 500 + last_error_detail: object = "Failed to create top-up invoice" + + # Some upstream Routstr nodes fail the first invoice request after warm-up + # and succeed immediately on retry. Retry once here so the UI stays single-click. + for attempt in range(2): + resp = await client.post( + f"{clean_url}/v1/balance/lightning/invoice", + json=request_json, + headers=headers, ) + if resp.status_code == 200: + data = resp.json() + return { + "ok": True, + "topup_data": { + "payment_request": data.get("bolt11"), + "invoice_id": data.get("invoice_id"), + "status": "pending", + }, + } + + logger.error( + f"Upstream topup request failed: {resp.text}", + extra={ + "provider_id": provider_id, + "attempt": attempt + 1, + "status_code": resp.status_code, + }, + ) + try: + last_error_detail = resp.json() + except Exception: + last_error_detail = resp.text + last_status_code = resp.status_code + + if resp.status_code < 500 or attempt == 1: + break + + await asyncio.sleep(0.2) + + raise HTTPException( + status_code=last_status_code, detail=last_error_detail + ) + + upstream_instance = _instantiate_provider(provider) + if not upstream_instance: + raise HTTPException( + status_code=400, detail="Could not instantiate provider" + ) + topup_data = await upstream_instance.initiate_topup(payload.amount) logger.info( @@ -1021,30 +1046,6 @@ async def get_provider_balance(provider_id: int) -> dict[str, object]: if not provider: raise HTTPException(status_code=404, detail="Provider not found") - # For Routstr providers, proxy the balance check - if provider.provider_type == "routstr": - import httpx - - async with httpx.AsyncClient() as client: - clean_url = provider.base_url.rstrip("/") - headers = {} - if provider.api_key: - headers["Authorization"] = f"Bearer {provider.api_key}" - resp = await client.get( - f"{clean_url}/v1/balance/info", - headers=headers, - ) - if resp.status_code == 200: - data = resp.json() - # Return balance in sats - balance = data.get("balance", 0) - if isinstance(balance, (int, float)): - return {"ok": True, "balance_data": balance // 1000} - return {"ok": True, "balance_data": balance} - else: - logger.error(f"Failed to fetch Routstr balance: {resp.text}") - return {"ok": False, "balance_data": None} - upstream_instance = _instantiate_provider(provider) if not upstream_instance: raise HTTPException( @@ -1053,6 +1054,16 @@ async def get_provider_balance(provider_id: int) -> dict[str, object]: try: balance_data = await upstream_instance.get_balance() + if balance_data is None: + logger.warning( + "Balance check returned no data", + extra={ + "provider_id": provider_id, + "provider_type": provider.provider_type, + "base_url": provider.base_url, + }, + ) + return {"ok": False, "balance_data": None} return {"ok": True, "balance_data": balance_data} except NotImplementedError as e: raise HTTPException( diff --git a/tests/integration/test_provider_management.py b/tests/integration/test_provider_management.py index b47ad848..b9d287f6 100644 --- a/tests/integration/test_provider_management.py +++ b/tests/integration/test_provider_management.py @@ -3,12 +3,15 @@ Integration tests for provider management functionality. Tests GET /v1/providers/ endpoint for listing and managing providers. """ +import time from typing import Any, Generator from unittest.mock import patch import pytest from httpx import AsyncClient +from routstr.core.admin import admin_sessions +from routstr.core.db import UpstreamProviderRow from routstr.nostr.discovery import _PROVIDERS_CACHE from .utils import ResponseValidator @@ -678,3 +681,83 @@ async def test_no_database_changes_during_provider_operations( assert final_diff["api_keys"]["added"] == [] assert final_diff["api_keys"]["modified"] == [] assert final_diff["api_keys"]["removed"] == [] + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_admin_routstr_topup_retries_transient_upstream_failure( + integration_client: AsyncClient, + integration_session: Any, +) -> None: + admin_token = "test-admin-token" + admin_sessions[admin_token] = int(time.time()) + 3600 + integration_client.headers["Authorization"] = f"Bearer {admin_token}" + + provider = UpstreamProviderRow( + provider_type="routstr", + base_url="https://node.example", + api_key="sk-upstream-test", + enabled=True, + provider_fee=1.01, + ) + integration_session.add(provider) + await integration_session.commit() + await integration_session.refresh(provider) + + class MockResponse: + def __init__(self, status_code: int, data: dict[str, Any] | None = None): + self.status_code = status_code + self._data = data or {} + self.text = str(self._data) + + def json(self) -> dict[str, Any]: + return self._data + + class MockAsyncClient: + def __init__(self): + self.calls = 0 + + async def __aenter__(self) -> "MockAsyncClient": + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + return None + + async def post( + self, url: str, json: dict[str, Any], headers: dict[str, str] + ) -> MockResponse: + self.calls += 1 + assert url == "https://node.example/v1/balance/lightning/invoice" + assert json["amount_sats"] == 10 + assert json["purpose"] == "topup" + assert json["api_key"] == "sk-upstream-test" + assert headers["Authorization"] == "Bearer sk-upstream-test" + + if self.calls == 1: + return MockResponse(500, {"detail": "warmup failure"}) + + return MockResponse( + 200, + { + "bolt11": "lnbc1testinvoice", + "invoice_id": "invoice-123", + }, + ) + + mock_client = MockAsyncClient() + + try: + with patch("httpx.AsyncClient", return_value=mock_client): + response = await integration_client.post( + f"/admin/api/upstream-providers/{provider.id}/topup", + json={"amount": 10}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["ok"] is True + assert data["topup_data"]["payment_request"] == "lnbc1testinvoice" + assert data["topup_data"]["invoice_id"] == "invoice-123" + assert mock_client.calls == 2 + finally: + admin_sessions.pop(admin_token, None) From d3dd3468530ff712a5d7c25cd8577083fffda44f Mon Sep 17 00:00:00 2001 From: Shroominic Date: Fri, 13 Mar 2026 18:20:08 +0800 Subject: [PATCH 06/21] fix: remove unrelated balance endpoint changes --- routstr/core/admin.py | 34 ++++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/routstr/core/admin.py b/routstr/core/admin.py index e15bcce8..572b035c 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -1046,6 +1046,30 @@ async def get_provider_balance(provider_id: int) -> dict[str, object]: if not provider: raise HTTPException(status_code=404, detail="Provider not found") + # For Routstr providers, proxy the balance check + if provider.provider_type == "routstr": + import httpx + + async with httpx.AsyncClient() as client: + clean_url = provider.base_url.rstrip("/") + headers = {} + if provider.api_key: + headers["Authorization"] = f"Bearer {provider.api_key}" + resp = await client.get( + f"{clean_url}/v1/balance/info", + headers=headers, + ) + if resp.status_code == 200: + data = resp.json() + # Return balance in sats + balance = data.get("balance", 0) + if isinstance(balance, (int, float)): + return {"ok": True, "balance_data": balance // 1000} + return {"ok": True, "balance_data": balance} + else: + logger.error(f"Failed to fetch Routstr balance: {resp.text}") + return {"ok": False, "balance_data": None} + upstream_instance = _instantiate_provider(provider) if not upstream_instance: raise HTTPException( @@ -1054,16 +1078,6 @@ async def get_provider_balance(provider_id: int) -> dict[str, object]: try: balance_data = await upstream_instance.get_balance() - if balance_data is None: - logger.warning( - "Balance check returned no data", - extra={ - "provider_id": provider_id, - "provider_type": provider.provider_type, - "base_url": provider.base_url, - }, - ) - return {"ok": False, "balance_data": None} return {"ok": True, "balance_data": balance_data} except NotImplementedError as e: raise HTTPException( From 93a368b1a2189faf2b40dbade8fec7a543d716b5 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Fri, 13 Mar 2026 18:39:06 +0800 Subject: [PATCH 07/21] fix(admin): handle routstr balance timeouts --- routstr/core/admin.py | 95 ++++++++++++++++++----------- routstr/upstream/routstr.py | 2 +- tests/unit/test_upstream_routstr.py | 70 +++++++++++++++++++++ 3 files changed, 131 insertions(+), 36 deletions(-) create mode 100644 tests/unit/test_upstream_routstr.py diff --git a/routstr/core/admin.py b/routstr/core/admin.py index c3160c9e..572b035c 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -1,3 +1,4 @@ +import asyncio import json import secrets from datetime import datetime, timezone @@ -865,12 +866,6 @@ async def initiate_provider_topup( if not provider: raise HTTPException(status_code=404, detail="Provider not found") - upstream_instance = _instantiate_provider(provider) - if not upstream_instance: - raise HTTPException( - status_code=400, detail="Could not instantiate provider" - ) - try: logger.info( f"Initiating top-up for provider {provider_id}", @@ -884,39 +879,69 @@ async def initiate_provider_topup( async with httpx.AsyncClient() as client: clean_url = provider.base_url.rstrip("/") - # Proxy the request to upstream Routstr - # Use the actual API key from the database - resp = await client.post( - f"{clean_url}/v1/balance/lightning/invoice", - json={ - "amount_sats": int(payload.amount), - "purpose": "topup", - "api_key": provider.api_key, - }, - headers={"Authorization": f"Bearer {provider.api_key}"} if provider.api_key else {}, + request_json = { + "amount_sats": int(payload.amount), + "purpose": "topup", + "api_key": provider.api_key, + } + headers = ( + {"Authorization": f"Bearer {provider.api_key}"} + if provider.api_key + else {} ) - if resp.status_code == 200: - data = resp.json() - return { - "ok": True, - "topup_data": { - "payment_request": data.get("bolt11"), - "invoice_id": data.get("invoice_id"), - "status": "pending", - }, - } - else: - logger.error(f"Upstream topup request failed: {resp.text}") - # Check if it's JSON error - try: - error_detail = resp.json() - except Exception: - error_detail = resp.text - raise HTTPException( - status_code=resp.status_code, detail=error_detail + last_status_code = 500 + last_error_detail: object = "Failed to create top-up invoice" + + # Some upstream Routstr nodes fail the first invoice request after warm-up + # and succeed immediately on retry. Retry once here so the UI stays single-click. + for attempt in range(2): + resp = await client.post( + f"{clean_url}/v1/balance/lightning/invoice", + json=request_json, + headers=headers, ) + if resp.status_code == 200: + data = resp.json() + return { + "ok": True, + "topup_data": { + "payment_request": data.get("bolt11"), + "invoice_id": data.get("invoice_id"), + "status": "pending", + }, + } + + logger.error( + f"Upstream topup request failed: {resp.text}", + extra={ + "provider_id": provider_id, + "attempt": attempt + 1, + "status_code": resp.status_code, + }, + ) + try: + last_error_detail = resp.json() + except Exception: + last_error_detail = resp.text + last_status_code = resp.status_code + + if resp.status_code < 500 or attempt == 1: + break + + await asyncio.sleep(0.2) + + raise HTTPException( + status_code=last_status_code, detail=last_error_detail + ) + + upstream_instance = _instantiate_provider(provider) + if not upstream_instance: + raise HTTPException( + status_code=400, detail="Could not instantiate provider" + ) + topup_data = await upstream_instance.initiate_topup(payload.amount) logger.info( diff --git a/routstr/upstream/routstr.py b/routstr/upstream/routstr.py index abf82a33..ab6dd0bd 100644 --- a/routstr/upstream/routstr.py +++ b/routstr/upstream/routstr.py @@ -83,7 +83,7 @@ class RoutstrUpstreamProvider(BaseUpstreamProvider): Balance in satoshis, or None if failed """ url = f"{self.base_url}/v1/balance/info" - headers = {"Authorization": f"Bearer {self.api_key}"} + headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {} async with httpx.AsyncClient() as client: try: diff --git a/tests/unit/test_upstream_routstr.py b/tests/unit/test_upstream_routstr.py new file mode 100644 index 00000000..d544fb2e --- /dev/null +++ b/tests/unit/test_upstream_routstr.py @@ -0,0 +1,70 @@ +from unittest.mock import Mock + +import httpx +import pytest + +from routstr.upstream.routstr import RoutstrUpstreamProvider + + +class DummyAsyncClient: + def __init__(self, response: Mock | None = None, error: Exception | None = None): + self.response = response + self.error = error + self.calls: list[dict[str, object]] = [] + + async def __aenter__(self) -> "DummyAsyncClient": + return self + + async def __aexit__(self, exc_type, exc, tb) -> bool: + return False + + async def get( + self, url: str, headers: dict[str, str], timeout: float + ) -> Mock: + self.calls.append({"url": url, "headers": headers, "timeout": timeout}) + if self.error is not None: + raise self.error + assert self.response is not None + return self.response + + +@pytest.mark.asyncio +async def test_get_balance_omits_auth_header_when_api_key_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + response = Mock() + response.json.return_value = {"balance_msats": 42000} + response.raise_for_status.return_value = None + + client = DummyAsyncClient(response=response) + monkeypatch.setattr("routstr.upstream.routstr.httpx.AsyncClient", lambda: client) + + provider = RoutstrUpstreamProvider(base_url="https://node.example", api_key="") + + balance = await provider.get_balance() + + assert balance == 42.0 + assert client.calls == [ + { + "url": "https://node.example/v1/balance/info", + "headers": {}, + "timeout": 10.0, + } + ] + + +@pytest.mark.asyncio +async def test_get_balance_returns_none_on_connect_timeout( + monkeypatch: pytest.MonkeyPatch, +) -> None: + client = DummyAsyncClient(error=httpx.ConnectTimeout("timed out")) + monkeypatch.setattr("routstr.upstream.routstr.httpx.AsyncClient", lambda: client) + + provider = RoutstrUpstreamProvider( + base_url="https://node.example", + api_key="secret", + ) + + balance = await provider.get_balance() + + assert balance is None From 59f8d31719ad9793b8ed071eafd5149d494b0015 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Fri, 13 Mar 2026 18:45:18 +0800 Subject: [PATCH 08/21] Handle Routstr admin balance timeouts --- routstr/core/admin.py | 49 +++++++++--- .../test_admin_provider_balance.py | 79 +++++++++++++++++++ 2 files changed, 119 insertions(+), 9 deletions(-) create mode 100644 tests/integration/test_admin_provider_balance.py diff --git a/routstr/core/admin.py b/routstr/core/admin.py index c3160c9e..81ac8309 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -1025,15 +1025,46 @@ async def get_provider_balance(provider_id: int) -> dict[str, object]: if provider.provider_type == "routstr": import httpx - async with httpx.AsyncClient() as client: - clean_url = provider.base_url.rstrip("/") - headers = {} - if provider.api_key: - headers["Authorization"] = f"Bearer {provider.api_key}" - resp = await client.get( - f"{clean_url}/v1/balance/info", - headers=headers, - ) + clean_url = provider.base_url.rstrip("/") + headers = {} + if provider.api_key: + headers["Authorization"] = f"Bearer {provider.api_key}" + + async with httpx.AsyncClient(timeout=10.0) as client: + try: + resp = await client.get( + f"{clean_url}/v1/balance/info", + headers=headers, + ) + except httpx.TimeoutException as exc: + logger.error( + "Timed out fetching Routstr provider balance", + extra={ + "provider_id": provider_id, + "base_url": clean_url, + "upstream_url": f"{clean_url}/v1/balance/info", + "error": str(exc), + }, + ) + raise HTTPException( + status_code=504, + detail="Timed out contacting upstream Routstr provider", + ) from exc + except httpx.RequestError as exc: + logger.error( + "Failed to fetch Routstr provider balance", + extra={ + "provider_id": provider_id, + "base_url": clean_url, + "upstream_url": f"{clean_url}/v1/balance/info", + "error": str(exc), + }, + ) + raise HTTPException( + status_code=502, + detail="Failed to contact upstream Routstr provider", + ) from exc + if resp.status_code == 200: data = resp.json() # Return balance in sats diff --git a/tests/integration/test_admin_provider_balance.py b/tests/integration/test_admin_provider_balance.py new file mode 100644 index 00000000..4167e68e --- /dev/null +++ b/tests/integration/test_admin_provider_balance.py @@ -0,0 +1,79 @@ +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock, patch + +import httpx +import pytest + +from routstr.core.admin import admin_sessions +from routstr.core.db import UpstreamProviderRow + + +async def _create_routstr_provider() -> UpstreamProviderRow: + return UpstreamProviderRow( + provider_type="routstr", + base_url="https://upstream.example", + api_key="", + enabled=True, + ) + + +def _admin_headers() -> dict[str, str]: + token = "test-admin-token" + admin_sessions[token] = int( + (datetime.now(timezone.utc) + timedelta(minutes=5)).timestamp() + ) + return {"Authorization": f"Bearer {token}"} + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_admin_routstr_provider_balance_timeout_returns_504( + integration_client: httpx.AsyncClient, + integration_session, +) -> None: + provider = await _create_routstr_provider() + integration_session.add(provider) + await integration_session.commit() + await integration_session.refresh(provider) + + request = httpx.Request("GET", f"{provider.base_url}/v1/balance/info") + timeout_error = httpx.ConnectTimeout("Connect timeout", request=request) + + with patch( + "httpx.AsyncHTTPTransport.handle_async_request", + new=AsyncMock(side_effect=timeout_error), + ): + response = await integration_client.get( + f"/admin/api/upstream-providers/{provider.id}/balance", + headers=_admin_headers(), + ) + + assert response.status_code == 504 + assert response.json()["detail"] == "Timed out contacting upstream Routstr provider" + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_admin_routstr_provider_balance_request_error_returns_502( + integration_client: httpx.AsyncClient, + integration_session, +) -> None: + provider = await _create_routstr_provider() + integration_session.add(provider) + await integration_session.commit() + await integration_session.refresh(provider) + + request = httpx.Request("GET", f"{provider.base_url}/v1/balance/info") + request_error = httpx.ConnectError("Connection failed", request=request) + + with patch( + "httpx.AsyncHTTPTransport.handle_async_request", + new=AsyncMock(side_effect=request_error), + ): + response = await integration_client.get( + f"/admin/api/upstream-providers/{provider.id}/balance", + headers=_admin_headers(), + ) + + assert response.status_code == 502 + assert response.json()["detail"] == "Failed to contact upstream Routstr provider" From 1ed8b29d64aebeb26f615755bbea6ad3e4da0c24 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Fri, 13 Mar 2026 18:51:30 +0800 Subject: [PATCH 09/21] style: format provider balance placeholder --- ui/components/provider-balance.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/ui/components/provider-balance.tsx b/ui/components/provider-balance.tsx index f2da5206..6ba57659 100644 --- a/ui/components/provider-balance.tsx +++ b/ui/components/provider-balance.tsx @@ -285,9 +285,7 @@ export function ProviderBalance({ id='topup_amount' type='number' placeholder={ - isRoutstr - ? 'Enter amount in sats' - : 'Enter amount (1-500)' + isRoutstr ? 'Enter amount in sats' : 'Enter amount (1-500)' } value={topupAmount} onChange={(e) => { From e31b45fa9e7b24b9c646470759a4eecb0a6a2e10 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Fri, 13 Mar 2026 18:51:30 +0800 Subject: [PATCH 10/21] test: type dummy async client exit hook --- tests/unit/test_upstream_routstr.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_upstream_routstr.py b/tests/unit/test_upstream_routstr.py index d544fb2e..8cb2427d 100644 --- a/tests/unit/test_upstream_routstr.py +++ b/tests/unit/test_upstream_routstr.py @@ -1,3 +1,4 @@ +from types import TracebackType from unittest.mock import Mock import httpx @@ -15,7 +16,12 @@ class DummyAsyncClient: async def __aenter__(self) -> "DummyAsyncClient": return self - async def __aexit__(self, exc_type, exc, tb) -> bool: + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> bool: return False async def get( From dd88e9b172b081d60defc0d778e265c39c701495 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Fri, 13 Mar 2026 18:51:30 +0800 Subject: [PATCH 11/21] test: annotate admin balance integration session --- tests/integration/test_admin_provider_balance.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/integration/test_admin_provider_balance.py b/tests/integration/test_admin_provider_balance.py index 4167e68e..c5e94fab 100644 --- a/tests/integration/test_admin_provider_balance.py +++ b/tests/integration/test_admin_provider_balance.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, patch import httpx import pytest +from sqlmodel.ext.asyncio.session import AsyncSession from routstr.core.admin import admin_sessions from routstr.core.db import UpstreamProviderRow @@ -29,7 +30,7 @@ def _admin_headers() -> dict[str, str]: @pytest.mark.asyncio async def test_admin_routstr_provider_balance_timeout_returns_504( integration_client: httpx.AsyncClient, - integration_session, + integration_session: AsyncSession, ) -> None: provider = await _create_routstr_provider() integration_session.add(provider) @@ -56,7 +57,7 @@ async def test_admin_routstr_provider_balance_timeout_returns_504( @pytest.mark.asyncio async def test_admin_routstr_provider_balance_request_error_returns_502( integration_client: httpx.AsyncClient, - integration_session, + integration_session: AsyncSession, ) -> None: provider = await _create_routstr_provider() integration_session.add(provider) From 9560050946efd1309089aee53e12a7e0d50f7dfd Mon Sep 17 00:00:00 2001 From: Shroominic Date: Fri, 13 Mar 2026 19:08:45 +0800 Subject: [PATCH 12/21] test: type routstr topup async client mock --- tests/integration/test_provider_management.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/integration/test_provider_management.py b/tests/integration/test_provider_management.py index b9d287f6..b7db0c6c 100644 --- a/tests/integration/test_provider_management.py +++ b/tests/integration/test_provider_management.py @@ -4,6 +4,7 @@ Tests GET /v1/providers/ endpoint for listing and managing providers. """ import time +from types import TracebackType from typing import Any, Generator from unittest.mock import patch @@ -714,13 +715,18 @@ async def test_admin_routstr_topup_retries_transient_upstream_failure( return self._data class MockAsyncClient: - def __init__(self): + def __init__(self) -> None: self.calls = 0 async def __aenter__(self) -> "MockAsyncClient": return self - async def __aexit__(self, exc_type, exc, tb) -> None: + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: return None async def post( From a79fdf7212308cee74adbfa43c42640359d6de98 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Fri, 13 Mar 2026 21:09:51 +0100 Subject: [PATCH 13/21] clean up --- ui/components/provider-card.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/ui/components/provider-card.tsx b/ui/components/provider-card.tsx index 9c0c99bd..480d4d1c 100644 --- a/ui/components/provider-card.tsx +++ b/ui/components/provider-card.tsx @@ -70,7 +70,6 @@ export function ProviderCard({ onDeleteModel, onOverrideModel, onUpdateApiKey, - availableMints, }: ProviderCardProps) { const [isKeyModalOpen, setIsKeyModalOpen] = useState(false); const hasDetails = Boolean(provider.api_version) || isExpanded; From 7d48b36be8c9626820157e7ecc407630a5597436 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Fri, 13 Mar 2026 22:23:50 +0100 Subject: [PATCH 14/21] add missing refund button --- ui/components/provider-card.tsx | 78 ++++++++++++++++++++++++++++++--- 1 file changed, 73 insertions(+), 5 deletions(-) diff --git a/ui/components/provider-card.tsx b/ui/components/provider-card.tsx index 480d4d1c..b48dc64b 100644 --- a/ui/components/provider-card.tsx +++ b/ui/components/provider-card.tsx @@ -19,11 +19,15 @@ import { Pencil, Trash2, Key, + RotateCcw, } from 'lucide-react'; import { ProviderBalance } from '@/components/provider-balance'; import { ProviderModelsPanel } from '@/components/provider-models-panel'; import { RoutstrCreateKeySection } from '@/components/providers/RoutstrCreateKeySection'; +import { RoutstrProviderService } from '@/lib/api/services/routstr-provider'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useState } from 'react'; +import { toast } from 'sonner'; import { cn } from '@/lib/utils'; import { Dialog, @@ -71,8 +75,28 @@ export function ProviderCard({ onOverrideModel, onUpdateApiKey, }: ProviderCardProps) { + const queryClient = useQueryClient(); const [isKeyModalOpen, setIsKeyModalOpen] = useState(false); const hasDetails = Boolean(provider.api_version) || isExpanded; + const isRoutstr = provider.provider_type === 'routstr'; + + const refundMutation = useMutation({ + mutationFn: () => RoutstrProviderService.refundBalance(provider.id), + onSuccess: (data) => { + if (data.ok) { + toast.success('Refund successful', { description: data.message }); + queryClient.invalidateQueries({ + queryKey: ['provider-balance', provider.id], + }); + queryClient.invalidateQueries({ queryKey: ['balances'] }); + } else { + toast.error('Refund failed', { description: data.message }); + } + }, + onError: (error: Error) => { + toast.error(`Refund error: ${error.message}`); + }, + }); return ( @@ -112,7 +136,7 @@ export function ProviderCard({
)} - {provider.provider_type === 'routstr' && ( + {isRoutstr && ( + )} +
- {tx.request_id && ( -
- - {tx.request_id} - - -
- )} - + ) : ( + — + )}
@@ -240,9 +222,7 @@ export default function TransactionsPage() {
- {formatDistanceToNow(tx.created_at * 1000, { - addSuffix: true, - })} + {format(tx.created_at * 1000, 'yyyy-MM-dd HH:mm:ss')} + - - - - + + - Transaction History +
+ Transaction History + {data && ( + + {data.transactions.length} entries + + )} +
+ {hasActiveFilters && ( + + Showing transactions filtered by {activeFilterDescription} + + )}
- + {isLoading ? (
- {Array.from({ length: 5 }).map((_, i) => ( - + {Array.from({ length: 8 }).map((_, index) => ( + ))}
- ) : ( - + ) : data?.transactions && data.transactions.length > 0 ? ( + @@ -172,7 +269,7 @@ export default function TransactionsPage() { - {data?.transactions.map((tx) => ( + {data.transactions.map((tx) => (
@@ -213,7 +310,9 @@ export default function TransactionsPage() {
) : ( - — + + — + )}
@@ -243,19 +342,21 @@ export default function TransactionsPage() {
))} - {data?.transactions.length === 0 && ( - - - No transactions found - - - )}
+ ) : ( + + + + + + No transactions found + + Try adjusting your filters or check back later. + + + )}
From 691927a996af99dcfd7c307d190ab734eeda57e2 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Mon, 16 Mar 2026 21:16:13 +0100 Subject: [PATCH 21/21] expose x-cashu header --- routstr/core/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routstr/core/main.py b/routstr/core/main.py index 461f8b6d..0cdc6a1f 100644 --- a/routstr/core/main.py +++ b/routstr/core/main.py @@ -181,7 +181,7 @@ app.add_middleware( allow_credentials=True, allow_methods=["*"], allow_headers=["*"], - expose_headers=["x-routstr-request-id"], + expose_headers=["x-routstr-request-id", "x-cashu"], ) # Add logging middleware