diff --git a/.env.example b/.env.example index d212b9ff..b5d86e6e 100644 --- a/.env.example +++ b/.env.example @@ -1,43 +1,39 @@ # Core Configuration UPSTREAM_BASE_URL=https://api.openai.com/v1 UPSTREAM_API_KEY=your-upstream-api-key -ADMIN_PASSWORD=secure-admin-password + +# ADMIN_PASSWORD=secure-admin-password # Database -DATABASE_URL=sqlite+aiosqlite:///keys.db +# DATABASE_URL=sqlite+aiosqlite:///keys.db # Node Information -NAME=My Routstr Node -DESCRIPTION=Fast AI API access with Bitcoin payments -NPUB=npub1... -HTTP_URL=https://api.mynode.com -ONION_URL=http://mynode.onion +# NAME=My Routstr Node +# DESCRIPTION=Fast AI API access with Bitcoin payments +# NSEC=nsec1... +# HTTP_URL=https://api.mynode.com +# ONION_URL=http://mynode.onion (auto fetched from compose) +# RELAYS="wss://relay.damus.io,wss://relay.nostr.band,wss://eden.nostr.land,wss://relay.routstr.com" +# CASHU_MINTS="https://mint.minibits.cash/Bitcoin,https://mint.cubabitcoin.org,https://ecashmint.otrta.me" +# RECEIVE_LN_ADDRESS= -# Cashu Configuration -CASHU_MINTS=https://mint.minibits.cash/Bitcoin -RECEIVE_LN_ADDRESS= - -# Pricing Configuration -MODEL_BASED_PRICING=true -COST_PER_REQUEST=1 -COST_PER_1K_INPUT_TOKENS=0 -COST_PER_1K_OUTPUT_TOKENS=0 -EXCHANGE_FEE=1.005 -UPSTREAM_PROVIDER_FEE=1.05 +# Custom Pricing Configuration +# MODEL_BASED_PRICING=true +# COST_PER_REQUEST=1 +# COST_PER_1K_INPUT_TOKENS=0 +# COST_PER_1K_OUTPUT_TOKENS=0 +# EXCHANGE_FEE=1.005 +# UPSTREAM_PROVIDER_FEE=1.05 # Network Configuration -CORS_ORIGINS=* -TOR_PROXY_URL=socks5://127.0.0.1:9050 +# CORS_ORIGINS=* +# TOR_PROXY_URL=socks5://127.0.0.1:9050 # Logging -LOG_LEVEL=INFO -ENABLE_CONSOLE_LOGGING=true +# LOG_LEVEL=INFO +# ENABLE_CONSOLE_LOGGING=true -# Model Management -MODELS_PATH=models.json -BASE_URL=https://openrouter.ai/api/v1 -SOURCE= - -# Optional Features -PREPAID_API_KEY= -PREPAID_BALANCE=0 \ No newline at end of file +# Custom Model Management +# BASE_URL=https://openrouter.ai/api/v1 +# MODELS_PATH=models.json +# SOURCE= diff --git a/README.md b/README.md index 861cad8f..07d25fe9 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ The most common settings are shown below. See `.env.example` for the full list. - `UPSTREAM_BASE_URL` – URL of the OpenAI-compatible service - `UPSTREAM_API_KEY` – API key for the upstream service (optional) -- `MODEL_BASED_PRICING` – Set to `true` to use pricing from `models.json` +- `FIXED_PRICING` – Set to `true` to use a fixed per-request price; `false` (default) uses model pricing from `models.json` - `ADMIN_PASSWORD` – Password for the `/admin/` dashboard - `CASHU_MINTS` – Comma-separated list of Cashu mint URLs - `NAME` – Name of the proxy diff --git a/compose.testing.yml b/compose.testing.yml index 36532d91..d734c447 100644 --- a/compose.testing.yml +++ b/compose.testing.yml @@ -19,10 +19,10 @@ services: - "ONION_URL=http://test.onion" - "CORS_ORIGINS=*" - "RECEIVE_LN_ADDRESS=test@routstr.com" - - "COST_PER_REQUEST=10" - - "COST_PER_1K_INPUT_TOKENS=0" - - "COST_PER_1K_OUTPUT_TOKENS=0" - - "MODEL_BASED_PRICING=true" + - "FIXED_COST_PER_REQUEST=10" + - "FIXED_PER_1K_INPUT_TOKENS=0" + - "FIXED_PER_1K_OUTPUT_TOKENS=0" + - "FIXED_PRICING=false" - "NSEC=nsec1testkey1234567890abcdef" - "REFUND_PROCESSING_INTERVAL=3600" - "MINIMUM_PAYOUT=1000" diff --git a/compose.yml b/compose.yml index aabf8d62..ec814c27 100644 --- a/compose.yml +++ b/compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: routstr: build: . @@ -26,12 +24,5 @@ services: depends_on: - routstr - # Legacy service definition to ensure cleanup of old container - router: - image: alpine:latest - command: /bin/true - profiles: - - cleanup - volumes: tor-data: diff --git a/docs/advanced/custom-pricing.md b/docs/advanced/custom-pricing.md index 1ab429c7..27d1f153 100644 --- a/docs/advanced/custom-pricing.md +++ b/docs/advanced/custom-pricing.md @@ -14,11 +14,11 @@ Routstr supports three pricing models: ### Configuration -Enable model-based pricing: +Enable model-based pricing (default behavior): ```bash # .env -MODEL_BASED_PRICING=true +FIXED_PRICING=false MODELS_PATH=/app/config/models.json EXCHANGE_FEE=1.005 # 0.5% exchange fee UPSTREAM_PROVIDER_FEE=1.05 # 5% provider margin @@ -118,14 +118,14 @@ if __name__ == "__main__": ### Configuration -Set up token-based pricing: +Set up token-based pricing overrides: ```bash # .env -MODEL_BASED_PRICING=false -COST_PER_REQUEST=1 # 1 sat base fee -COST_PER_1K_INPUT_TOKENS=5 # 5 sats per 1K input -COST_PER_1K_OUTPUT_TOKENS=15 # 15 sats per 1K output +FIXED_PRICING=false # use model pricing +FIXED_COST_PER_REQUEST=1 # optional base fee +FIXED_PER_1K_INPUT_TOKENS=5 # optional override +FIXED_PER_1K_OUTPUT_TOKENS=15 # optional override ``` ### Custom Token Counting diff --git a/docs/advanced/nostr.md b/docs/advanced/nostr.md index e3aebcb1..dd0fecaf 100644 --- a/docs/advanced/nostr.md +++ b/docs/advanced/nostr.md @@ -102,7 +102,7 @@ Configure which relays to publish to: DEFAULT_RELAYS = [ "wss://relay.damus.io", "wss://relay.nostr.band", - "wss://nostr.mom", + "wss://relay.routstr.com", "wss://nos.lol" ] diff --git a/docs/api/endpoints.md b/docs/api/endpoints.md index 345eed00..abff6d64 100644 --- a/docs/api/endpoints.md +++ b/docs/api/endpoints.md @@ -436,6 +436,27 @@ Authorization: Bearer sk-... ## Provider Discovery +## Admin Settings + +These endpoints are protected by the Admin cookie (`admin_password` set to your configured admin password). + +### Get Settings + +```http +GET /admin/api/settings +``` + +Returns the current application settings (sensitive values may be redacted). + +### Update Settings + +```http +PATCH /admin/api/settings +Content-Type: application/json +``` + +Body is a partial JSON of settings fields to update. Validated and persisted to the database. + ### List Providers Get available upstream providers. diff --git a/docs/api/overview.md b/docs/api/overview.md index a09ccc21..47abbd7a 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -347,7 +347,7 @@ GET /health Response: { "status": "healthy", - "version": "0.1.2", + "version": "0.1.3", "timestamp": "2024-01-01T00:00:00Z", "checks": { "database": "ok", diff --git a/docs/contributing/code-structure.md b/docs/contributing/code-structure.md index e1ff26ee..4b141b20 100644 --- a/docs/contributing/code-structure.md +++ b/docs/contributing/code-structure.md @@ -348,7 +348,7 @@ Project metadata and dependencies: ```toml [project] name = "routstr" -version = "0.1.2" +version = "0.1.3" dependencies = [ "fastapi[standard]>=0.115", "sqlmodel>=0.0.24", diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md index 904553b7..246678c6 100644 --- a/docs/getting-started/configuration.md +++ b/docs/getting-started/configuration.md @@ -1,6 +1,6 @@ # Configuration -Routstr Core is configured through environment variables. This guide covers all available options. +Routstr Core is configured via a single settings row in the database. Environment variables are only used on first run to seed that row (with a few computed defaults like `ONION_URL`). After that, the database is the source of truth. You can update settings at runtime via the admin API. `DATABASE_URL` is always env-only. ## Environment Variables @@ -33,19 +33,21 @@ Routstr Core is configured through environment variables. This guide covers all | Variable | Description | Default | Required | |----------|-------------|---------|----------| -| `MODEL_BASED_PRICING` | Enable model-specific pricing from models.json | `false` | ❌ | -| `COST_PER_REQUEST` | Fixed cost per API request in sats | `1` | ❌ | -| `COST_PER_1K_INPUT_TOKENS` | Cost per 1000 input tokens in sats | `0` | ❌ | -| `COST_PER_1K_OUTPUT_TOKENS` | Cost per 1000 output tokens in sats | `0` | ❌ | +| `FIXED_PRICING` | Force fixed per-request pricing (ignore model token pricing) | `false` | ❌ | +| `FIXED_COST_PER_REQUEST` | Fixed cost per API request in sats | `1` | ❌ | +| `FIXED_PER_1K_INPUT_TOKENS` | Optional override: sats per 1000 input tokens | `0` | ❌ | +| `FIXED_PER_1K_OUTPUT_TOKENS` | Optional override: sats per 1000 output tokens | `0` | ❌ | | `EXCHANGE_FEE` | Exchange rate markup (1.005 = 0.5% fee) | `1.005` | ❌ | | `UPSTREAM_PROVIDER_FEE` | Provider fee markup (1.05 = 5% fee) | `1.05` | ❌ | -### Network Configuration +### Network & Discovery | Variable | Description | Default | Required | |----------|-------------|---------|----------| | `CORS_ORIGINS` | Comma-separated list of allowed CORS origins | `*` | ❌ | | `TOR_PROXY_URL` | SOCKS5 proxy URL for Tor connections | `socks5://127.0.0.1:9050` | ❌ | +| `RELAYS` | Comma-separated nostr relays used for provider discovery | sane defaults | ❌ | +| `PROVIDERS_REFRESH_INTERVAL_SECONDS` | Provider cache refresh interval | `300` | ❌ | ### Logging Configuration @@ -60,6 +62,7 @@ Routstr Core is configured through environment variables. This guide covers all |----------|-------------|---------|----------| | `CHAT_COMPLETIONS_API_VERSION` | Append `api-version` to `/chat/completions` (Azure OpenAI) | - | ❌ | | `DATABASE_URL` | SQLite database connection string | `sqlite+aiosqlite:///keys.db` | ❌ | +| `REFUND_CACHE_TTL_SECONDS` | Cache TTL for refund responses (seconds) | `3600` | ❌ | ## Configuration Examples @@ -78,7 +81,6 @@ ADMIN_PASSWORD=my-secure-password # .env UPSTREAM_BASE_URL=https://api.anthropic.com/v1 UPSTREAM_API_KEY=your-anthropic-key -MODEL_BASED_PRICING=true MODELS_PATH=/app/config/anthropic-models.json ``` @@ -115,36 +117,21 @@ ONION_URL=http://lightningai.onion CASHU_MINTS=https://mint1.com,https://mint2.com ``` -## Pricing Models +## Pricing -### Fixed Pricing +- Default: pricing comes from your `models.json`. +- Force fixed per-request pricing: set `FIXED_PRICING=true` and `FIXED_COST_PER_REQUEST`. +- Optional token overrides when using model pricing: set + `FIXED_PER_1K_INPUT_TOKENS` and/or `FIXED_PER_1K_OUTPUT_TOKENS`. +- Legacy envs are still accepted and mapped automatically: + `MODEL_BASED_PRICING` → `!FIXED_PRICING`, `COST_PER_REQUEST` → `FIXED_COST_PER_REQUEST`, + `COST_PER_1K_*` → `FIXED_PER_1K_*`. -Simple per-request pricing: +Example fixed pricing: ```bash -MODEL_BASED_PRICING=false -COST_PER_REQUEST=10 # 10 sats per request -``` - -### Token-Based Pricing - -Charge based on token usage: - -```bash -MODEL_BASED_PRICING=false -COST_PER_REQUEST=1 # 1 sat base fee -COST_PER_1K_INPUT_TOKENS=5 # 5 sats per 1k input -COST_PER_1K_OUTPUT_TOKENS=15 # 15 sats per 1k output -``` - -### Model-Based Pricing - -Use dynamic pricing from models.json: - -```bash -MODEL_BASED_PRICING=true -EXCHANGE_FEE=1.01 # 1% exchange fee -UPSTREAM_PROVIDER_FEE=1.00 # No additional markup +FIXED_PRICING=true +FIXED_COST_PER_REQUEST=10 ``` ## Custom Models Configuration diff --git a/docs/getting-started/docker.md b/docs/getting-started/docker.md index 0620937f..9357a44f 100644 --- a/docs/getting-started/docker.md +++ b/docs/getting-started/docker.md @@ -115,8 +115,8 @@ NPUB=npub1... HTTP_URL=https://api.mynode.com ONION_URL=http://mynode.onion -# Pricing -MODEL_BASED_PRICING=true +# Pricing (optional) +FIXED_PRICING=false EXCHANGE_FEE=1.005 UPSTREAM_PROVIDER_FEE=1.05 ``` diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 0ef96c02..e179adcb 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -67,7 +67,7 @@ You should see: { "name": "ARoutstrNode", "description": "A Routstr Node", - "version": "0.1.2", + "version": "0.1.3", "npub": "", "mints": ["https://mint.minibits.cash/Bitcoin"], "models": {...} diff --git a/docs/user-guide/models-pricing.md b/docs/user-guide/models-pricing.md index 24ce7d6c..c5f08850 100644 --- a/docs/user-guide/models-pricing.md +++ b/docs/user-guide/models-pricing.md @@ -11,8 +11,8 @@ Routstr supports three pricing models: Simple per-request charging: ```bash -MODEL_BASED_PRICING=false -COST_PER_REQUEST=10 # 10 sats per request +FIXED_PRICING=true +FIXED_COST_PER_REQUEST=10 # 10 sats per request ``` **Best for:** @@ -26,10 +26,10 @@ COST_PER_REQUEST=10 # 10 sats per request Charge based on actual token usage: ```bash -MODEL_BASED_PRICING=false -COST_PER_REQUEST=1 # 1 sat base fee -COST_PER_1K_INPUT_TOKENS=5 # 5 sats per 1K input -COST_PER_1K_OUTPUT_TOKENS=15 # 15 sats per 1K output +FIXED_PRICING=false # use model pricing +FIXED_COST_PER_REQUEST=1 # optional base fee +FIXED_PER_1K_INPUT_TOKENS=5 # optional override +FIXED_PER_1K_OUTPUT_TOKENS=15 # optional override ``` **Best for:** @@ -43,7 +43,7 @@ COST_PER_1K_OUTPUT_TOKENS=15 # 15 sats per 1K output Dynamic pricing based on model costs: ```bash -MODEL_BASED_PRICING=true +FIXED_PRICING=false EXCHANGE_FEE=1.005 # 0.5% exchange fee UPSTREAM_PROVIDER_FEE=1.05 # 5% provider fee ``` diff --git a/migrations/versions/a1b2c3d4e5f6_add_settings_table.py b/migrations/versions/a1b2c3d4e5f6_add_settings_table.py new file mode 100644 index 00000000..f3c898b8 --- /dev/null +++ b/migrations/versions/a1b2c3d4e5f6_add_settings_table.py @@ -0,0 +1,35 @@ +"""add settings table + +Revision ID: a1b2c3d4e5f6 +Revises: 042f6b77d69d +Create Date: 2025-09-06 00:00:00.000000 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "a1b2c3d4e5f6" +down_revision = "042f6b77d69d" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "settings", + sa.Column("id", sa.Integer(), primary_key=True, nullable=False), + sa.Column("data", sa.Text(), nullable=False), + sa.Column( + "updated_at", + sa.DateTime(), + nullable=True, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + ) + + +def downgrade() -> None: + op.drop_table("settings") diff --git a/migrations/versions/c0ffee123456_create_models_table.py b/migrations/versions/c0ffee123456_create_models_table.py new file mode 100644 index 00000000..992bb2d7 --- /dev/null +++ b/migrations/versions/c0ffee123456_create_models_table.py @@ -0,0 +1,37 @@ +"""create models table + +Revision ID: c0ffee123456 +Revises: a1b2c3d4e5f6 +Create Date: 2025-09-10 00:00:00.000000 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "c0ffee123456" +down_revision = "a1b2c3d4e5f6" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "models", + sa.Column("id", sa.String(), primary_key=True, nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("created", sa.Integer(), nullable=False), + sa.Column("description", sa.Text(), nullable=False), + sa.Column("context_length", sa.Integer(), nullable=False), + sa.Column("architecture", sa.Text(), nullable=False), + sa.Column("pricing", sa.Text(), nullable=False), + sa.Column("sats_pricing", sa.Text(), nullable=True), + sa.Column("per_request_limits", sa.Text(), nullable=True), + sa.Column("top_provider", sa.Text(), nullable=True), + ) + + +def downgrade() -> None: + op.drop_table("models") diff --git a/pyproject.toml b/pyproject.toml index a7078f19..1f554101 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "routstr" -version = "0.1.2" +version = "0.1.3" description = "Payment proxy for your LLM endpoint using cashu and nostr." readme = "README.md" requires-python = ">=3.11" @@ -18,6 +18,7 @@ dependencies = [ "marshmallow>=3.13,<4.0", "websockets>=12.0", "nostr>=0.0.2", + "mdurl==0.1.2", ] [dependency-groups] diff --git a/routstr/__init__.py b/routstr/__init__.py index 7a1bc151..1bdd854c 100644 --- a/routstr/__init__.py +++ b/routstr/__init__.py @@ -1,7 +1,3 @@ -import dotenv - -dotenv.load_dotenv() - from .core.main import app as fastapi_app # noqa __all__ = ["fastapi_app"] diff --git a/routstr/auth.py b/routstr/auth.py index 6202a69a..1398bfa6 100644 --- a/routstr/auth.py +++ b/routstr/auth.py @@ -7,18 +7,14 @@ from sqlmodel import col, update from .core import get_logger from .core.db import ApiKey, AsyncSession +from .core.settings import settings from .payment.cost_caculation import ( CostData, CostDataError, MaxCostData, calculate_cost, ) -from .wallet import ( - PRIMARY_MINT_URL, - TRUSTED_MINTS, - credit_balance, - deserialize_token_from_string, -) +from .wallet import credit_balance, deserialize_token_from_string logger = get_logger(__name__) @@ -165,12 +161,12 @@ async def validate_bearer_key( "has_expiry_time": bool(key_expiry_time), }, ) - if token_obj.mint in TRUSTED_MINTS: + if token_obj.mint in settings.cashu_mints: refund_currency = token_obj.unit refund_mint_url = token_obj.mint else: refund_currency = "sat" - refund_mint_url = PRIMARY_MINT_URL + refund_mint_url = settings.primary_mint new_key = ApiKey( hashed_key=hashed_key, @@ -426,7 +422,7 @@ async def adjust_payment_for_tokens( }, ) - match calculate_cost(response_data, deducted_max_cost): + match await calculate_cost(response_data, deducted_max_cost, session): case MaxCostData() as cost: logger.debug( "Using max cost data (no token adjustment)", @@ -637,3 +633,10 @@ async def adjust_payment_for_tokens( } }, ) + # Fallback return to satisfy type checker; execution should not reach here + return { + "base_msats": deducted_max_cost, + "input_msats": 0, + "output_msats": 0, + "total_msats": deducted_max_cost, + } diff --git a/routstr/balance.py b/routstr/balance.py index 97039905..76e87498 100644 --- a/routstr/balance.py +++ b/routstr/balance.py @@ -1,6 +1,5 @@ import asyncio import hashlib -import os from time import monotonic from typing import Annotated, NoReturn @@ -9,7 +8,8 @@ from pydantic import BaseModel from .auth import validate_bearer_key from .core.db import ApiKey, AsyncSession, get_session -from .wallet import PRIMARY_MINT_URL, credit_balance, send_to_lnurl, send_token +from .core.settings import settings +from .wallet import credit_balance, send_to_lnurl, send_token router = APIRouter() balance_router = APIRouter(prefix="/v1/balance") @@ -34,6 +34,7 @@ async def account_info(key: ApiKey = Depends(get_key_from_header)) -> dict: return { "api_key": "sk-" + key.hashed_key, "balance": key.balance, + "reserved": key.reserved_balance, } @@ -65,6 +66,7 @@ async def wallet_info(key: ApiKey = Depends(get_key_from_header)) -> dict: return { "api_key": "sk-" + key.hashed_key, "balance": key.balance, + "reserved": key.reserved_balance, } @@ -102,7 +104,7 @@ async def topup_wallet_endpoint( return {"msats": amount_msats} -_REFUND_CACHE_TTL_SECONDS: int = int(os.environ.get("REFUND_CACHE_TTL_SECONDS", "3600")) +_REFUND_CACHE_TTL_SECONDS: int = settings.refund_cache_ttl_seconds _refund_cache_lock: asyncio.Lock = asyncio.Lock() _refund_cache: dict[str, tuple[float, dict[str, str]]] = {} @@ -157,11 +159,13 @@ async def refund_wallet_endpoint( try: if key.refund_address: if key.refund_currency == "sat": - remaining_balance = remaining_balance_msats * 1000 + remaining_balance = remaining_balance_msats // 1000 + from .core.settings import settings as global_settings + await send_to_lnurl( remaining_balance, key.refund_currency or "sat", - key.refund_mint_url or PRIMARY_MINT_URL, + key.refund_mint_url or global_settings.primary_mint, key.refund_address, ) result = {"recipient": key.refund_address} diff --git a/routstr/core/admin.py b/routstr/core/admin.py index 4bf6b00a..b52fdde6 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -3,13 +3,12 @@ import os from datetime import datetime, timezone from pathlib import Path -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import HTMLResponse from pydantic import BaseModel from sqlmodel import select from ..wallet import ( - TRUSTED_MINTS, fetch_all_balances, get_proofs_per_mint_and_unit, get_wallet, @@ -18,12 +17,152 @@ from ..wallet import ( ) from .db import ApiKey, create_session from .logging import get_logger +from .settings import SettingsService, settings logger = get_logger(__name__) admin_router = APIRouter(prefix="/admin", include_in_schema=False) +def require_admin_api(request: Request) -> None: + admin_cookie = request.cookies.get("admin_password") + if not admin_cookie or admin_cookie != settings.admin_password: + raise HTTPException(status_code=403, detail="Unauthorized") + + +def is_admin_authenticated(request: Request) -> bool: + admin_cookie = request.cookies.get("admin_password") + return bool(admin_cookie and admin_cookie == settings.admin_password) + + +@admin_router.get( + "/partials/balances", + dependencies=[Depends(require_admin_api)], + response_class=HTMLResponse, +) +async def partial_balances(request: Request) -> str: + ( + balance_details, + total_wallet_balance_sats, + total_user_balance_sats, + owner_balance, + ) = await fetch_all_balances() + # Provide JSON for client usage + # Embed a script tag to update balanceDetails and the UI markup + rows = "".join( + [ + f"""
Your balance = Total wallet - User balance
+| Hashed Key | +Balance (mSats) | +Total Spent (mSats) | +Total Requests | +Refund Address | +Refund Time | +
|---|
Your balance = Total wallet - User balance
- -