diff --git a/migrations/versions/lightning_invoices_table.py b/migrations/versions/lightning_invoices_table.py new file mode 100644 index 00000000..0b795e8d --- /dev/null +++ b/migrations/versions/lightning_invoices_table.py @@ -0,0 +1,39 @@ +"""Add lightning_invoices table + +Revision ID: lightning_invoices +Revises: a1a1a1a1a1a1 +Create Date: 2025-12-10 21:00:00.000000 +""" + +import sqlalchemy as sa +import sqlmodel +from alembic import op + +revision = "lightning_invoices" +down_revision = "a1a1a1a1a1a1" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "lightning_invoices", + sa.Column("id", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("bolt11", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("amount_sats", sa.Integer(), nullable=False), + sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("payment_hash", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("status", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("api_key_hash", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("purpose", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("created_at", sa.Integer(), nullable=False), + sa.Column("expires_at", sa.Integer(), nullable=False), + sa.Column("paid_at", sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("bolt11"), + sa.UniqueConstraint("payment_hash"), + ) + + +def downgrade() -> None: + op.drop_table("lightning_invoices") diff --git a/routstr/balance.py b/routstr/balance.py index 883c4673..62358761 100644 --- a/routstr/balance.py +++ b/routstr/balance.py @@ -10,6 +10,7 @@ from .auth import validate_bearer_key from .core.db import ApiKey, AsyncSession, get_session from .core.logging import get_logger from .core.settings import settings +from .lightning import lightning_router from .wallet import credit_balance, recieve_token, send_to_lnurl, send_token router = APIRouter() @@ -238,6 +239,8 @@ async def wallet_catch_all(path: str) -> NoReturn: ) +balance_router.include_router(lightning_router) balance_router.include_router(router) + deprecated_wallet_router = APIRouter(prefix="/v1/wallet", include_in_schema=False) deprecated_wallet_router.include_router(router) diff --git a/routstr/core/db.py b/routstr/core/db.py index c6effbe3..96fe91d0 100644 --- a/routstr/core/db.py +++ b/routstr/core/db.py @@ -1,4 +1,5 @@ import os +import time from contextlib import asynccontextmanager from typing import AsyncGenerator @@ -71,6 +72,28 @@ class ModelRow(SQLModel, table=True): # type: ignore upstream_provider: "UpstreamProviderRow" = Relationship(back_populates="models") +class LightningInvoice(SQLModel, table=True): # type: ignore + __tablename__ = "lightning_invoices" + + id: str = Field(primary_key=True, description="Unique invoice identifier") + bolt11: str = Field(description="BOLT11 invoice string", unique=True) + amount_sats: int = Field(description="Amount in satoshis") + description: str = Field(description="Invoice description") + payment_hash: str = Field(description="Payment hash for tracking", unique=True) + status: str = Field( + default="pending", description="pending, paid, expired, cancelled" + ) + api_key_hash: str | None = Field( + default=None, description="Associated API key hash for topup operations" + ) + purpose: str = Field(description="create or topup") + created_at: int = Field( + default_factory=lambda: int(time.time()), description="Unix timestamp" + ) + expires_at: int = Field(description="Unix timestamp when invoice expires") + paid_at: int | None = Field(default=None, description="Unix timestamp when paid") + + class UpstreamProviderRow(SQLModel, table=True): # type: ignore __tablename__ = "upstream_providers" id: int | None = Field(default=None, primary_key=True) diff --git a/routstr/lightning.py b/routstr/lightning.py new file mode 100644 index 00000000..50d1608d --- /dev/null +++ b/routstr/lightning.py @@ -0,0 +1,270 @@ +import hashlib +import secrets +import time + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession + +from .core.db import ApiKey, LightningInvoice, get_session +from .core.logging import get_logger +from .core.settings import settings +from .wallet import get_wallet + +logger = get_logger(__name__) + +lightning_router = APIRouter(prefix="/lightning") + + +class InvoiceCreateRequest(BaseModel): + amount_sats: int = Field(gt=0, le=1_000_000, description="Amount in satoshis") + purpose: str = Field(description="create or topup", pattern="^(create|topup)$") + api_key: str | None = Field( + default=None, description="Required for topup operations" + ) + + +class InvoiceCreateResponse(BaseModel): + invoice_id: str + bolt11: str + amount_sats: int + expires_at: int + payment_hash: str + + +class InvoiceStatusResponse(BaseModel): + status: str + api_key: str | None = None + amount_sats: int + paid_at: int | None = None + created_at: int + expires_at: int + + +class InvoiceRecoverRequest(BaseModel): + bolt11: str = Field(description="BOLT11 invoice string") + + +async def generate_lightning_invoice( + amount_sats: int, description: str +) -> tuple[str, str]: + wallet = await get_wallet(settings.primary_mint, "sat") + quote = await wallet.request_mint(amount_sats) + return quote.request, quote.quote + + +def generate_invoice_id() -> str: + return secrets.token_urlsafe(16) + + +@lightning_router.post("/invoice", response_model=InvoiceCreateResponse) +async def create_invoice( + request: InvoiceCreateRequest, + session: AsyncSession = Depends(get_session), +) -> InvoiceCreateResponse: + if request.purpose == "topup" and not request.api_key: + raise HTTPException( + status_code=400, detail="api_key is required for topup operations" + ) + + if request.purpose == "topup" and request.api_key: + if not request.api_key.startswith("sk-"): + raise HTTPException(status_code=400, detail="Invalid API key format") + + api_key = await session.get(ApiKey, request.api_key[3:]) + if not api_key: + raise HTTPException(status_code=404, detail="API key not found") + + try: + description = f"Routstr {request.purpose} {request.amount_sats} sats" + bolt11, payment_hash = await generate_lightning_invoice( + request.amount_sats, description + ) + + invoice_id = generate_invoice_id() + expires_at = int(time.time()) + 3600 # 1 hour expiry + + invoice = LightningInvoice( + id=invoice_id, + bolt11=bolt11, + amount_sats=request.amount_sats, + description=description, + payment_hash=payment_hash, + status="pending", + api_key_hash=request.api_key[3:] if request.api_key else None, + purpose=request.purpose, + expires_at=expires_at, + ) + + session.add(invoice) + await session.commit() + + logger.info( + "Lightning invoice created", + extra={ + "invoice_id": invoice_id, + "amount_sats": request.amount_sats, + "purpose": request.purpose, + "expires_at": expires_at, + }, + ) + + return InvoiceCreateResponse( + invoice_id=invoice_id, + bolt11=bolt11, + amount_sats=request.amount_sats, + expires_at=expires_at, + payment_hash=payment_hash, + ) + + except Exception as e: + logger.error(f"Failed to create Lightning invoice: {e}") + raise HTTPException( + status_code=500, detail="Failed to create Lightning invoice" + ) + + +@lightning_router.get( + "/invoice/{invoice_id}/status", response_model=InvoiceStatusResponse +) +async def get_invoice_status( + invoice_id: str, + session: AsyncSession = Depends(get_session), +) -> InvoiceStatusResponse: + invoice = await session.get(LightningInvoice, invoice_id) + if not invoice: + raise HTTPException(status_code=404, detail="Invoice not found") + + if invoice.status == "pending" and int(time.time()) > invoice.expires_at: + invoice.status = "expired" + await session.commit() + + if invoice.status == "pending": + await check_invoice_payment(invoice, session) + + api_key = None + if invoice.status == "paid" and invoice.purpose == "create": + if invoice.api_key_hash: + api_key = f"sk-{invoice.api_key_hash}" + elif ( + invoice.status == "paid" and invoice.purpose == "topup" and invoice.api_key_hash + ): + api_key = f"sk-{invoice.api_key_hash}" + + return InvoiceStatusResponse( + status=invoice.status, + api_key=api_key, + amount_sats=invoice.amount_sats, + paid_at=invoice.paid_at, + created_at=invoice.created_at, + expires_at=invoice.expires_at, + ) + + +@lightning_router.post("/recover", response_model=InvoiceStatusResponse) +async def recover_invoice( + request: InvoiceRecoverRequest, + session: AsyncSession = Depends(get_session), +) -> InvoiceStatusResponse: + result = await session.exec( + select(LightningInvoice).where(LightningInvoice.bolt11 == request.bolt11) + ) + invoice = result.first() + + if not invoice: + raise HTTPException(status_code=404, detail="Invoice not found") + + if invoice.status == "pending": + await check_invoice_payment(invoice, session) + + api_key = None + if invoice.status == "paid": + if invoice.purpose == "create" and invoice.api_key_hash: + api_key = f"sk-{invoice.api_key_hash}" + elif invoice.purpose == "topup" and invoice.api_key_hash: + api_key = f"sk-{invoice.api_key_hash}" + + return InvoiceStatusResponse( + status=invoice.status, + api_key=api_key, + amount_sats=invoice.amount_sats, + paid_at=invoice.paid_at, + created_at=invoice.created_at, + expires_at=invoice.expires_at, + ) + + +async def check_invoice_payment( + invoice: LightningInvoice, session: AsyncSession +) -> None: + try: + wallet = await get_wallet(settings.primary_mint, "sat") + + mint_status = await wallet.get_mint_quote(invoice.payment_hash) + + if mint_status.paid: + invoice.status = "paid" + invoice.paid_at = int(time.time()) + + if invoice.purpose == "create": + api_key = await create_api_key_from_invoice(invoice, session) + invoice.api_key_hash = api_key.hashed_key + elif invoice.purpose == "topup" and invoice.api_key_hash: + await topup_api_key_from_invoice(invoice, session) + + await session.commit() + + logger.info( + "Lightning invoice paid", + extra={ + "invoice_id": invoice.id, + "amount_sats": invoice.amount_sats, + "purpose": invoice.purpose, + "api_key_hash": invoice.api_key_hash[:8] + "..." + if invoice.api_key_hash + else None, + }, + ) + + except Exception as e: + logger.error(f"Failed to check invoice payment: {e}") + + +async def create_api_key_from_invoice( + invoice: LightningInvoice, session: AsyncSession +) -> ApiKey: + wallet = await get_wallet(settings.primary_mint, "sat") + await wallet.mint(invoice.amount_sats, quote_id=invoice.payment_hash) + + dummy_token = f"invoice-{invoice.id}-{invoice.payment_hash}" + hashed_key = hashlib.sha256(dummy_token.encode()).hexdigest() + + api_key = ApiKey( + hashed_key=hashed_key, + balance=invoice.amount_sats * 1000, # Convert to msats + refund_currency="sat", + refund_mint_url=settings.primary_mint, + ) + + session.add(api_key) + await session.flush() + + return api_key + + +async def topup_api_key_from_invoice( + invoice: LightningInvoice, session: AsyncSession +) -> None: + wallet = await get_wallet(settings.primary_mint, "sat") + await wallet.mint(invoice.amount_sats, quote_id=invoice.payment_hash) + + if not invoice.api_key_hash: + raise ValueError("No API key associated with topup invoice") + + api_key = await session.get(ApiKey, invoice.api_key_hash) + if not api_key: + raise ValueError("Associated API key not found") + + api_key.balance += invoice.amount_sats * 1000 # Convert to msats + await session.flush() diff --git a/ui/components/landing/api-key-manager.tsx b/ui/components/landing/api-key-manager.tsx new file mode 100644 index 00000000..b1d765f1 --- /dev/null +++ b/ui/components/landing/api-key-manager.tsx @@ -0,0 +1,284 @@ +'use client'; + +import { type JSX, useCallback, useState } from 'react'; +import { Copy, RefreshCcw, Trash2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Separator } from '@/components/ui/separator'; + +type WalletSnapshot = { + apiKey: string; + balanceMsats: number; + reservedMsats: number; +}; + +type RefundReceipt = { + token?: string; + recipient?: string; + sats?: string; + msats?: string; +}; + +interface ApiKeyManagerProps { + baseUrl: string; + apiKey?: string; + walletInfo?: WalletSnapshot | null; + onApiKeyChanged?: (apiKey: string) => void; + onWalletInfoUpdated?: (walletInfo: WalletSnapshot | null) => void; + onRefundComplete?: (receipt: RefundReceipt) => void; +} + +async function fetchWalletInfo( + baseUrl: string, + apiKey: string +): Promise { + const response = await fetch(`${baseUrl}/v1/balance/info`, { + cache: 'no-store', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || 'Unable to load wallet info'); + } + + const payload = (await response.json()) as { + api_key: string; + balance: number; + reserved?: number; + }; + + return { + apiKey: payload.api_key || apiKey, + balanceMsats: payload.balance ?? 0, + reservedMsats: payload.reserved ?? 0, + }; +} + +function formatMsats(msats: number): string { + return new Intl.NumberFormat('en-US').format(msats); +} + +function formatSats(msats: number): string { + return new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000)); +} + +export function ApiKeyManager({ + baseUrl, + apiKey = '', + walletInfo = null, + onApiKeyChanged, + onWalletInfoUpdated, + onRefundComplete, +}: ApiKeyManagerProps): JSX.Element { + const [apiKeyInput, setApiKeyInput] = useState(apiKey); + const [isSyncingBalance, setIsSyncingBalance] = useState(false); + const [isRefunding, setIsRefunding] = useState(false); + const [hasInteractedManage, setHasInteractedManage] = useState(false); + + const handleCopy = useCallback(async (value: string): Promise => { + if (!value) { + return; + } + if (typeof navigator === 'undefined' || !navigator.clipboard) { + toast.error('Clipboard API unavailable'); + return; + } + try { + await navigator.clipboard.writeText(value); + toast.success('Copied to clipboard'); + } catch (error) { + console.error(error); + toast.error('Unable to copy'); + } + }, []); + + const handleSyncBalance = useCallback(async (): Promise => { + const activeApiKey = apiKeyInput.trim(); + if (!activeApiKey) { + toast.error('Paste an API key first'); + return; + } + + setIsSyncingBalance(true); + try { + const snapshot = await fetchWalletInfo(baseUrl, activeApiKey); + onWalletInfoUpdated?.(snapshot); + toast.success('Balance synced'); + } catch (error) { + console.error(error); + toast.error( + error instanceof Error ? error.message : 'Failed to sync balance' + ); + } finally { + setIsSyncingBalance(false); + } + }, [apiKeyInput, baseUrl, onWalletInfoUpdated]); + + const handleRefund = useCallback(async (): Promise => { + const activeApiKey = apiKeyInput.trim(); + if (!activeApiKey) { + toast.error('Paste an API key first'); + return; + } + + setIsRefunding(true); + try { + const response = await fetch(`${baseUrl}/v1/balance/refund`, { + method: 'POST', + headers: { + Authorization: `Bearer ${activeApiKey}`, + }, + }); + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || 'Refund failed'); + } + const receipt = (await response.json()) as RefundReceipt; + onRefundComplete?.(receipt); + onWalletInfoUpdated?.(null); + setApiKeyInput(''); + toast.success('Refund completed'); + } catch (error) { + console.error(error); + toast.error(error instanceof Error ? error.message : 'Refund failed'); + } finally { + setIsRefunding(false); + } + }, [apiKeyInput, baseUrl, onRefundComplete, onWalletInfoUpdated]); + + const handleApiKeyChange = useCallback( + (newKey: string) => { + setApiKeyInput(newKey); + onApiKeyChanged?.(newKey); + if (newKey !== apiKey) { + onWalletInfoUpdated?.(null); + } + }, + [apiKey, onApiKeyChanged, onWalletInfoUpdated] + ); + + const activeApiKey = apiKeyInput.trim(); + const showManageDetails = + hasInteractedManage || Boolean(walletInfo) || activeApiKey.length > 0; + + return ( + + + + + API Key Management + +

+ Manage your existing API keys and balances +

+
+ +
+
+ Manage existing key + {walletInfo && ( + + {formatSats(walletInfo.balanceMsats)} sats + + )} +
+
+ handleApiKeyChange(event.target.value)} + placeholder='sk-...' + className='font-mono text-sm' + onFocus={() => setHasInteractedManage(true)} + /> +
+ + +
+
+ + {showManageDetails && ( +
+
+
+

+ Spendable +

+

+ {walletInfo + ? `${formatSats(walletInfo.balanceMsats)} sats` + : '—'} +

+ {walletInfo && ( +

+ {formatMsats(walletInfo.balanceMsats)} msats +

+ )} +
+
+

+ Reserved +

+

+ {walletInfo + ? `${formatSats(walletInfo.reservedMsats)} sats` + : '—'} +

+ {walletInfo && ( +

+ {formatMsats(walletInfo.reservedMsats)} msats +

+ )} +
+
+ + + +
+
+ Refund remaining balance +
+
+ + + Burns the key and returns a fresh Cashu token. + +
+
+
+ )} +
+
+
+ ); +} diff --git a/ui/components/landing/cashu-payment-workflow.tsx b/ui/components/landing/cashu-payment-workflow.tsx new file mode 100644 index 00000000..6fdd6553 --- /dev/null +++ b/ui/components/landing/cashu-payment-workflow.tsx @@ -0,0 +1,452 @@ +'use client'; + +import { type JSX, useCallback, useState } from 'react'; +import { Copy, KeyRound, RefreshCcw, Trash2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; +import { Input } from '@/components/ui/input'; +import { Separator } from '@/components/ui/separator'; + +type WalletSnapshot = { + apiKey: string; + balanceMsats: number; + reservedMsats: number; +}; + +type RefundReceipt = { + token?: string; + recipient?: string; + sats?: string; + msats?: string; +}; + +interface CashuPaymentWorkflowProps { + baseUrl: string; + apiKey?: string; + walletInfo?: WalletSnapshot | null; + onApiKeyCreated?: (apiKey: string, walletInfo: WalletSnapshot) => void; + onApiKeyChanged?: (apiKey: string) => void; + onWalletInfoUpdated?: (walletInfo: WalletSnapshot | null) => void; + onRefundComplete?: (receipt: RefundReceipt) => void; +} + +async function fetchWalletInfo( + baseUrl: string, + apiKey: string +): Promise { + const response = await fetch(`${baseUrl}/v1/balance/info`, { + cache: 'no-store', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || 'Unable to load wallet info'); + } + + const payload = (await response.json()) as { + api_key: string; + balance: number; + reserved?: number; + }; + + return { + apiKey: payload.api_key || apiKey, + balanceMsats: payload.balance ?? 0, + reservedMsats: payload.reserved ?? 0, + }; +} + +function formatMsats(msats: number): string { + return new Intl.NumberFormat('en-US').format(msats); +} + +function formatSats(msats: number): string { + return new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000)); +} + +export function CashuPaymentWorkflow({ + baseUrl, + apiKey = '', + walletInfo = null, + onApiKeyCreated, + onApiKeyChanged, + onWalletInfoUpdated, + onRefundComplete, +}: CashuPaymentWorkflowProps): JSX.Element { + const [initialToken, setInitialToken] = useState(''); + const [topupToken, setTopupToken] = useState(''); + const [apiKeyInput, setApiKeyInput] = useState(apiKey); + const [isCreatingKey, setIsCreatingKey] = useState(false); + const [isTopupLoading, setIsTopupLoading] = useState(false); + const [isRefunding, setIsRefunding] = useState(false); + const [isSyncingBalance, setIsSyncingBalance] = useState(false); + const [hasInteractedCreate, setHasInteractedCreate] = useState(false); + const [hasInteractedManage, setHasInteractedManage] = useState(false); + const [hasInteractedTopup, setHasInteractedTopup] = useState(false); + + const activeApiKey = apiKeyInput.trim(); + + const handleCopy = useCallback(async (value: string): Promise => { + if (!value) { + return; + } + if (typeof navigator === 'undefined' || !navigator.clipboard) { + toast.error('Clipboard API unavailable'); + return; + } + try { + await navigator.clipboard.writeText(value); + toast.success('Copied to clipboard'); + } catch (error) { + console.error(error); + toast.error('Unable to copy'); + } + }, []); + + const handleCreateKey = useCallback(async (): Promise => { + if (!initialToken.trim()) { + toast.error('Cashu token required'); + return; + } + + setIsCreatingKey(true); + + try { + const params = new URLSearchParams({ + initial_balance_token: initialToken.trim(), + }); + const response = await fetch( + `${baseUrl}/v1/balance/create?${params.toString()}`, + { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + } + ); + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || 'Failed to create API key'); + } + const payload = (await response.json()) as { + api_key: string; + balance: number; + }; + const snapshot: WalletSnapshot = { + apiKey: payload.api_key, + balanceMsats: payload.balance ?? 0, + reservedMsats: 0, + }; + + setApiKeyInput(snapshot.apiKey); + onApiKeyCreated?.(snapshot.apiKey, snapshot); + setInitialToken(''); + toast.success('API key ready'); + } catch (error) { + console.error(error); + toast.error( + error instanceof Error ? error.message : 'Failed to create API key' + ); + } finally { + setIsCreatingKey(false); + } + }, [initialToken, baseUrl, onApiKeyCreated]); + + const handleSyncBalance = useCallback(async (): Promise => { + if (!activeApiKey) { + toast.error('Paste an API key first'); + return; + } + + setIsSyncingBalance(true); + try { + const snapshot = await fetchWalletInfo(baseUrl, activeApiKey); + onWalletInfoUpdated?.(snapshot); + toast.success('Balance synced'); + } catch (error) { + console.error(error); + toast.error( + error instanceof Error ? error.message : 'Failed to sync balance' + ); + } finally { + setIsSyncingBalance(false); + } + }, [activeApiKey, baseUrl, onWalletInfoUpdated]); + + const handleTopup = useCallback(async (): Promise => { + if (!activeApiKey) { + toast.error('Paste an API key first'); + return; + } + if (!topupToken.trim()) { + toast.error('Cashu token required for top-up'); + return; + } + + setIsTopupLoading(true); + try { + const response = await fetch(`${baseUrl}/v1/balance/topup`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${activeApiKey}`, + }, + body: JSON.stringify({ cashu_token: topupToken.trim() }), + }); + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || 'Failed to top up'); + } + const payload = (await response.json()) as { msats: number }; + toast.success(`Added ${formatSats(payload.msats)} sats`); + setTopupToken(''); + const snapshot = await fetchWalletInfo(baseUrl, activeApiKey); + onApiKeyCreated?.(snapshot.apiKey, snapshot); + } catch (error) { + console.error(error); + toast.error(error instanceof Error ? error.message : 'Top-up failed'); + } finally { + setIsTopupLoading(false); + } + }, [activeApiKey, baseUrl, topupToken, onApiKeyCreated]); + + const handleRefund = useCallback(async (): Promise => { + if (!activeApiKey) { + toast.error('Paste an API key first'); + return; + } + + setIsRefunding(true); + try { + const response = await fetch(`${baseUrl}/v1/balance/refund`, { + method: 'POST', + headers: { + Authorization: `Bearer ${activeApiKey}`, + }, + }); + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || 'Refund failed'); + } + const payload = (await response.json()) as RefundReceipt; + onRefundComplete?.(payload); + onWalletInfoUpdated?.(null); + setApiKeyInput(''); + toast.success('Refund requested'); + } catch (error) { + console.error(error); + toast.error(error instanceof Error ? error.message : 'Refund failed'); + } finally { + setIsRefunding(false); + } + }, [activeApiKey, baseUrl, onRefundComplete, onWalletInfoUpdated]); + + const handleApiKeyChange = useCallback( + (newKey: string) => { + setApiKeyInput(newKey); + onApiKeyChanged?.(newKey); + if (newKey !== apiKey) { + onWalletInfoUpdated?.(null); + } + }, + [apiKey, onApiKeyChanged, onWalletInfoUpdated] + ); + + const showCreateDetails = + hasInteractedCreate || initialToken.trim().length > 0; + const showManageDetails = hasInteractedManage || Boolean(walletInfo); + const showTopupDetails = hasInteractedTopup || topupToken.trim().length > 0; + const canTopup = Boolean(activeApiKey); + + return ( + + + + + API key workflow + +

+ Sections expand as soon as you interact +

+
+ +
+
+ 1 · Create key + {showCreateDetails && ( + Cashu token detected + )} +
+