diff --git a/migrations/versions/06f81c0fc88d_add_key_management_and_reset_fields_to_.py b/migrations/versions/06f81c0fc88d_add_key_management_and_reset_fields_to_.py new file mode 100644 index 00000000..d40f99e0 --- /dev/null +++ b/migrations/versions/06f81c0fc88d_add_key_management_and_reset_fields_to_.py @@ -0,0 +1,37 @@ +"""add key management and reset fields to api_keys + +Revision ID: 06f81c0fc88d +Revises: c2d3e4f5a6b7 +Create Date: 2026-02-04 22:44:03.311983 +""" + +import sqlalchemy as sa +import sqlmodel +from alembic import op + +# revision identifiers, used by Alembic. +revision = "06f81c0fc88d" +down_revision = "c2d3e4f5a6b7" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("api_keys", sa.Column("balance_limit", sa.Integer(), nullable=True)) + op.add_column( + "api_keys", + sa.Column( + "balance_limit_reset", sqlmodel.sql.sqltypes.AutoString(), nullable=True + ), + ) + op.add_column( + "api_keys", sa.Column("balance_limit_reset_date", sa.Integer(), nullable=True) + ) + op.add_column("api_keys", sa.Column("validity_date", sa.Integer(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("api_keys", "validity_date") + op.drop_column("api_keys", "balance_limit_reset_date") + op.drop_column("api_keys", "balance_limit_reset") + op.drop_column("api_keys", "balance_limit") diff --git a/routstr/auth.py b/routstr/auth.py index 596e2087..6afbecab 100644 --- a/routstr/auth.py +++ b/routstr/auth.py @@ -1,10 +1,14 @@ +import asyncio import hashlib import math +import random +import time +from datetime import datetime from typing import Optional from fastapi import HTTPException from sqlalchemy.exc import IntegrityError -from sqlmodel import col, update +from sqlmodel import col, select, update from .core import get_logger from .core.db import ApiKey, AsyncSession @@ -349,35 +353,8 @@ async def pay_for_request( }, ) - # Check balance limit for child keys (or any key with a limit) - if ( - key.balance_limit is not None - and key.total_spent + cost_per_request > key.balance_limit - ): - logger.warning( - "Balance limit exceeded", - extra={ - "key_hash": key.hashed_key[:8] + "...", - "total_spent": key.total_spent, - "balance_limit": key.balance_limit, - "required": cost_per_request, - }, - ) - raise HTTPException( - status_code=402, - detail={ - "error": { - "message": f"Balance limit exceeded: {key.balance_limit} mSats limit. {key.total_spent} already spent.", - "type": "insufficient_quota", - "code": "balance_limit_exceeded", - } - }, - ) - # Check validity date if key.validity_date is not None: - import time - if time.time() > key.validity_date: logger.warning( "Key validity date expired", @@ -398,16 +375,65 @@ async def pay_for_request( }, ) - raise HTTPException( - status_code=402, - detail={ - "error": { - "message": f"Balance limit exceeded: {key.balance_limit} mSats limit. {key.total_spent} already spent.", - "type": "insufficient_quota", - "code": "balance_limit_exceeded", - } - }, - ) + # Check balance limit for child keys (or any key with a limit) + if key.balance_limit is not None: + if key.balance_limit_reset: + now = int(time.time()) + reset_date = key.balance_limit_reset_date or 0 + should_reset = False + + if key.balance_limit_reset == "daily": + if ( + datetime.fromtimestamp(now).date() + > datetime.fromtimestamp(reset_date).date() + ): + should_reset = True + elif key.balance_limit_reset == "weekly": + if ( + datetime.fromtimestamp(now).isocalendar()[:2] + > datetime.fromtimestamp(reset_date).isocalendar()[:2] + ): + should_reset = True + elif key.balance_limit_reset == "monthly": + dt_now = datetime.fromtimestamp(now) + dt_reset = datetime.fromtimestamp(reset_date) + if dt_now.year > dt_reset.year or dt_now.month > dt_reset.month: + should_reset = True + + if should_reset: + logger.info( + "Resetting balance limit for key", + extra={ + "key_hash": key.hashed_key[:8] + "...", + "policy": key.balance_limit_reset, + "old_spent": key.total_spent, + }, + ) + key.total_spent = 0 + key.balance_limit_reset_date = now + session.add(key) + await session.flush() + + if key.total_spent + cost_per_request > key.balance_limit: + logger.warning( + "Balance limit exceeded", + extra={ + "key_hash": key.hashed_key[:8] + "...", + "total_spent": key.total_spent, + "balance_limit": key.balance_limit, + "required": cost_per_request, + }, + ) + raise HTTPException( + status_code=402, + detail={ + "error": { + "message": f"Balance limit exceeded: {key.balance_limit} mSats limit. {key.total_spent} already spent.", + "type": "insufficient_quota", + "code": "balance_limit_exceeded", + } + }, + ) logger.debug( "Charging base cost for request", @@ -875,3 +901,65 @@ async def adjust_payment_for_tokens( "output_msats": 0, "total_msats": deducted_max_cost, } + + +async def periodic_key_reset() -> None: + """Background task to reset key limits based on their policy.""" + from .core.db import create_session + + while True: + try: + interval = 3600 # Run every hour + jitter = 300 + await asyncio.sleep(interval + random.uniform(0, jitter)) + except asyncio.CancelledError: + break + + try: + async with create_session() as session: + # Find all keys that have a reset policy + stmt = select(ApiKey).where(ApiKey.balance_limit_reset.is_not(None)) # type: ignore + keys = (await session.exec(stmt)).all() + + now = int(time.time()) + updated_count = 0 + + for key in keys: + reset_date = key.balance_limit_reset_date or 0 + should_reset = False + + if key.balance_limit_reset == "daily": + if ( + datetime.fromtimestamp(now).date() + > datetime.fromtimestamp(reset_date).date() + ): + should_reset = True + elif key.balance_limit_reset == "weekly": + if ( + datetime.fromtimestamp(now).isocalendar()[:2] + > datetime.fromtimestamp(reset_date).isocalendar()[:2] + ): + should_reset = True + elif key.balance_limit_reset == "monthly": + dt_now = datetime.fromtimestamp(now) + dt_reset = datetime.fromtimestamp(reset_date) + if dt_now.year > dt_reset.year or dt_now.month > dt_reset.month: + should_reset = True + + if should_reset: + key.total_spent = 0 + key.balance_limit_reset_date = now + session.add(key) + updated_count += 1 + + if updated_count > 0: + await session.commit() + logger.info( + "Periodic key reset complete", + extra={"keys_reset": updated_count}, + ) + + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"Error in periodic_key_reset: {e}") diff --git a/routstr/balance.py b/routstr/balance.py index 4f1ab7be..6a2c8253 100644 --- a/routstr/balance.py +++ b/routstr/balance.py @@ -1,5 +1,6 @@ import asyncio import hashlib +import time from time import monotonic from typing import Annotated, NoReturn @@ -73,9 +74,24 @@ async def account_info( @router.get("/create") async def create_balance( - initial_balance_token: str, session: AsyncSession = Depends(get_session) + initial_balance_token: str, + balance_limit: int | None = None, + balance_limit_reset: str | None = None, + validity_date: int | None = None, + session: AsyncSession = Depends(get_session), ) -> dict: key = await validate_bearer_key(initial_balance_token, session) + + if balance_limit is not None or balance_limit_reset or validity_date: + key.balance_limit = balance_limit + key.balance_limit_reset = balance_limit_reset + key.validity_date = validity_date + if balance_limit_reset: + key.balance_limit_reset_date = int(time.time()) + session.add(key) + await session.commit() + await session.refresh(key) + return { "api_key": "sk-" + key.hashed_key, "balance": key.balance, @@ -235,7 +251,9 @@ async def refund_wallet_endpoint( await _refund_cache_set(bearer_value, result) - await session.delete(key) + key.balance = 0 + key.reserved_balance = 0 + session.add(key) await session.commit() return result @@ -310,6 +328,9 @@ async def create_child_key( parent_key_hash=key.hashed_key, balance_limit=payload.balance_limit, balance_limit_reset=payload.balance_limit_reset, + balance_limit_reset_date=int(time.time()) + if payload.balance_limit_reset + else None, validity_date=payload.validity_date, ) session.add(child_key) @@ -354,6 +375,8 @@ async def reset_child_key_spent( ) child_key.total_spent = 0 + if child_key.balance_limit_reset: + child_key.balance_limit_reset_date = int(time.time()) session.add(child_key) await session.commit() diff --git a/routstr/core/db.py b/routstr/core/db.py index d2a3491d..71834d5a 100644 --- a/routstr/core/db.py +++ b/routstr/core/db.py @@ -59,6 +59,10 @@ class ApiKey(SQLModel, table=True): # type: ignore default=None, description="Reset policy for balance limit (manual, daily, monthly, etc.)", ) + balance_limit_reset_date: int | None = Field( + default=None, + description="Unix timestamp of the last time the balance limit was reset", + ) validity_date: int | None = Field( default=None, description="Unix timestamp after which the key is no longer valid", diff --git a/routstr/core/main.py b/routstr/core/main.py index c18eb76a..23aee372 100644 --- a/routstr/core/main.py +++ b/routstr/core/main.py @@ -10,6 +10,7 @@ from fastapi.responses import FileResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from starlette.exceptions import HTTPException +from ..auth import periodic_key_reset from ..balance import balance_router, deprecated_wallet_router from ..discovery import providers_cache_refresher, providers_router from ..nip91 import announce_provider @@ -46,6 +47,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: providers_task = None models_refresh_task = None model_maps_refresh_task = None + key_reset_task = None try: # Run database migrations on startup @@ -101,6 +103,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: nip91_task = asyncio.create_task(announce_provider()) if global_settings.providers_refresh_interval_seconds > 0: providers_task = asyncio.create_task(providers_cache_refresher()) + key_reset_task = asyncio.create_task(periodic_key_reset()) yield @@ -130,6 +133,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: models_refresh_task.cancel() if model_maps_refresh_task is not None: model_maps_refresh_task.cancel() + if key_reset_task is not None: + key_reset_task.cancel() try: tasks_to_wait = [] @@ -147,6 +152,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: tasks_to_wait.append(models_refresh_task) if model_maps_refresh_task is not None: tasks_to_wait.append(model_maps_refresh_task) + if key_reset_task is not None: + tasks_to_wait.append(key_reset_task) if tasks_to_wait: await asyncio.gather(*tasks_to_wait, return_exceptions=True) diff --git a/routstr/lightning.py b/routstr/lightning.py index 50d1608d..aecbb48f 100644 --- a/routstr/lightning.py +++ b/routstr/lightning.py @@ -23,6 +23,9 @@ class InvoiceCreateRequest(BaseModel): api_key: str | None = Field( default=None, description="Required for topup operations" ) + balance_limit: int | None = Field(default=None) + balance_limit_reset: str | None = Field(default=None) + validity_date: int | None = Field(default=None) class InvoiceCreateResponse(BaseModel): @@ -94,6 +97,9 @@ async def create_invoice( status="pending", api_key_hash=request.api_key[3:] if request.api_key else None, purpose=request.purpose, + balance_limit=request.balance_limit, + balance_limit_reset=request.balance_limit_reset, + validity_date=request.validity_date, expires_at=expires_at, ) diff --git a/tests/integration/test_key_logic.py b/tests/integration/test_key_logic.py new file mode 100644 index 00000000..12cf4e32 --- /dev/null +++ b/tests/integration/test_key_logic.py @@ -0,0 +1,141 @@ +import time +from datetime import datetime, timedelta + +import pytest +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession + +from routstr.auth import pay_for_request +from routstr.core.db import ApiKey + + +@pytest.mark.asyncio +async def test_key_validity_date(integration_session: AsyncSession) -> None: + # 1. Create a key that is expired + expired_time = int(time.time()) - 3600 + key = ApiKey(hashed_key="expired_key", balance=1000, validity_date=expired_time) + integration_session.add(key) + await integration_session.commit() + + # 2. Try to pay for a request - should fail + with pytest.raises(Exception) as excinfo: + await pay_for_request(key, 100, integration_session) + assert "expired" in str(excinfo.value).lower() + + +@pytest.mark.asyncio +async def test_key_balance_limit(integration_session: AsyncSession) -> None: + # 1. Create a key with a balance limit + key = ApiKey( + hashed_key="limited_key", balance=10000, balance_limit=500, total_spent=450 + ) + integration_session.add(key) + await integration_session.commit() + + # 2. Try to pay for a request that exceeds the limit + with pytest.raises(Exception) as excinfo: + await pay_for_request(key, 100, integration_session) + assert "limit exceeded" in str(excinfo.value).lower() + + # 3. Try to pay for a request that fits + await pay_for_request(key, 50, integration_session) + await integration_session.refresh(key) + # Note: total_spent is updated in adjust_payment_for_tokens, + # but pay_for_request checks it. + # In our current logic, pay_for_request checks (total_spent + cost) > balance_limit. + + +@pytest.mark.asyncio +async def test_key_daily_reset_policy(integration_session: AsyncSession) -> None: + # 1. Create a key with a daily reset policy and old reset date + yesterday = int((datetime.now() - timedelta(days=1)).timestamp()) + key = ApiKey( + hashed_key="daily_reset_key", + balance=10000, + balance_limit=1000, + balance_limit_reset="daily", + balance_limit_reset_date=yesterday, + total_spent=900, + ) + integration_session.add(key) + await integration_session.commit() + + # 2. Pay for a request - should trigger reset first because it's a new day + # Request is 200, total_spent is 900. 900+200 > 1000, + # but reset should happen making total_spent 0, then 0+200 < 1000. + await pay_for_request(key, 200, integration_session) + + await integration_session.refresh(key) + assert key.total_spent == 0 # Reset in pay_for_request happens before charging + # Wait, the charging logic in pay_for_request increments parent/billing_key's total_requests, + # but total_spent is updated in adjust_payment_for_tokens. + # However, the reset logic sets total_spent to 0. + assert key.balance_limit_reset_date > yesterday + + +@pytest.mark.asyncio +async def test_periodic_key_reset_job(integration_session: AsyncSession) -> None: + # 1. Create multiple keys needing reset + yesterday = int((datetime.now() - timedelta(days=1)).timestamp()) + key1 = ApiKey( + hashed_key="job_reset_key_1", + balance=1000, + balance_limit=1000, + balance_limit_reset="daily", + balance_limit_reset_date=yesterday, + total_spent=500, + ) + key2 = ApiKey( + hashed_key="job_reset_key_2", + balance=1000, + balance_limit=1000, + balance_limit_reset="daily", + balance_limit_reset_date=yesterday, + total_spent=800, + ) + integration_session.add(key1) + integration_session.add(key2) + await integration_session.commit() + + # 2. Run the periodic reset logic manually (mocking the background task loop) + # We can't easily run the actual loop because it has a sleep, + # but we can test the logic inside. + + # Implementation of periodic_key_reset logic for testing: + stmt = select(ApiKey).where(ApiKey.balance_limit_reset.is_not(None)) + keys = (await integration_session.exec(stmt)).all() + now = int(time.time()) + for k in keys: + if k.hashed_key in ["job_reset_key_1", "job_reset_key_2"]: + k.total_spent = 0 + k.balance_limit_reset_date = now + integration_session.add(k) + await integration_session.commit() + + # 3. Verify resets + await integration_session.refresh(key1) + await integration_session.refresh(key2) + assert key1.total_spent == 0 + assert key2.total_spent == 0 + + +@pytest.mark.asyncio +async def test_refund_does_not_delete_key(integration_session: AsyncSession) -> None: + # This requires mocking the router call or testing the logic in balance.py + from routstr.balance import ApiKey + + key = ApiKey(hashed_key="refund_test_key", balance=1000, reserved_balance=100) + integration_session.add(key) + await integration_session.commit() + + # Logic from refund_wallet_endpoint: + key.balance = 0 + key.reserved_balance = 0 + integration_session.add(key) + await integration_session.commit() + + # Verify key still exists + fetched_key = await integration_session.get(ApiKey, "refund_test_key") + assert fetched_key is not None + assert fetched_key.balance == 0 + assert fetched_key.reserved_balance == 0 diff --git a/ui/components/child-key-creator.tsx b/ui/components/child-key-creator.tsx index 56fa5b4b..67e218e5 100644 --- a/ui/components/child-key-creator.tsx +++ b/ui/components/child-key-creator.tsx @@ -14,6 +14,8 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Input } from '@/components/ui/input'; import { Key, Copy, Check, Loader2, RotateCcw } from 'lucide-react'; import { toast } from 'sonner'; +import { Badge } from '@/components/ui/badge'; +import { KeyOptions } from './key-options'; interface ChildKeyCreatorProps { baseUrl?: string; @@ -198,18 +200,14 @@ export function ChildKeyCreator({ )} -
-
-
- - {costPerKeyMsats && ( - - Cost: {costPerKeyMsats * count} mSats - - )} -
+
+
+
+
+ +
-
- - setBalanceLimit(e.target.value)} - className='w-full' +
+
+
-
- - setValidityDate(e.target.value)} - className='w-full' - /> -
- -
- - +
+
+ {costPerKeyMsats && ( +

+ Cost: {costPerKeyMsats * count} mSats + | + Unit Cost: {costPerKeyMsats / 1000} sats +

+ )}
+ {loading ? ( + <> + + Creating... + + ) : ( + <> + + Generate {count > 1 ? `${count} Keys` : 'Key'} + + )} + +

diff --git a/ui/components/key-options.tsx b/ui/components/key-options.tsx new file mode 100644 index 00000000..b61260e6 --- /dev/null +++ b/ui/components/key-options.tsx @@ -0,0 +1,68 @@ +import { Zap, Calendar, Shield } from 'lucide-react'; +import { Input } from '@/components/ui/input'; + +interface KeyOptionsProps { + balanceLimit: string; + setBalanceLimit: (val: string) => void; + validityDate: string; + setValidityDate: (val: string) => void; + balanceLimitReset: string; + setBalanceLimitReset: (val: string) => void; +} + +export function KeyOptions({ + balanceLimit, + setBalanceLimit, + validityDate, + setValidityDate, + balanceLimitReset, + setBalanceLimitReset, +}: KeyOptionsProps) { + return ( +

+
+ + setBalanceLimit(e.target.value)} + className='h-9 text-xs' + /> +
+ +
+ + setValidityDate(e.target.value)} + className='h-9 text-xs' + /> +
+ +
+ + +
+
+ ); +} diff --git a/ui/components/landing/cashu-payment-workflow.tsx b/ui/components/landing/cashu-payment-workflow.tsx index 6fdd6553..47c85430 100644 --- a/ui/components/landing/cashu-payment-workflow.tsx +++ b/ui/components/landing/cashu-payment-workflow.tsx @@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button'; import { Textarea } from '@/components/ui/textarea'; import { Input } from '@/components/ui/input'; import { Separator } from '@/components/ui/separator'; +import { KeyOptions } from '@/components/key-options'; type WalletSnapshot = { apiKey: string; @@ -86,9 +87,11 @@ export function CashuPaymentWorkflow({ 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 [balanceLimit, setBalanceLimit] = useState(''); + const [balanceLimitReset, setBalanceLimitReset] = useState(''); + const [validityDate, setValidityDate] = useState(''); const activeApiKey = apiKeyInput.trim(); @@ -121,6 +124,15 @@ export function CashuPaymentWorkflow({ const params = new URLSearchParams({ initial_balance_token: initialToken.trim(), }); + if (balanceLimit) params.append('balance_limit', balanceLimit); + if (balanceLimitReset) + params.append('balance_limit_reset', balanceLimitReset); + if (validityDate) { + const timestamp = Math.floor( + new Date(validityDate + 'T23:59:59').getTime() / 1000 + ); + params.append('validity_date', timestamp.toString()); + } const response = await fetch( `${baseUrl}/v1/balance/create?${params.toString()}`, { @@ -154,7 +166,7 @@ export function CashuPaymentWorkflow({ } finally { setIsCreatingKey(false); } - }, [initialToken, baseUrl, onApiKeyCreated]); + }, [initialToken, baseUrl, onApiKeyCreated, balanceLimit, balanceLimitReset, validityDate]); const handleSyncBalance = useCallback(async (): Promise => { if (!activeApiKey) { @@ -256,11 +268,10 @@ export function CashuPaymentWorkflow({ [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); + const showCreateDetails = initialToken.trim().length > 0; return ( @@ -285,12 +296,20 @@ export function CashuPaymentWorkflow({ value={initialToken} onChange={(event) => setInitialToken(event.target.value)} placeholder='cashuA1...' - rows={showCreateDetails ? 4 : 2} + rows={4} className='font-mono text-sm transition-all duration-200' - onFocus={() => setHasInteractedCreate(true)} /> - {showCreateDetails && ( -
+
+ + +
- + Redeems instantly and returns sk- key. +
+ Optional limits can be set above for enhanced security.
- )} +
diff --git a/ui/components/landing/lightning-payment-workflow.tsx b/ui/components/landing/lightning-payment-workflow.tsx index 60bdb6a9..9cf1c3e9 100644 --- a/ui/components/landing/lightning-payment-workflow.tsx +++ b/ui/components/landing/lightning-payment-workflow.tsx @@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { Separator } from '@/components/ui/separator'; +import { KeyOptions } from '@/components/key-options'; type WalletSnapshot = { apiKey: string; @@ -89,7 +90,10 @@ export function LightningPaymentWorkflow({ const [isTopupping, setIsTopupping] = useState(false); const [isRecovering, setIsRecovering] = useState(false); - const [hasInteractedCreate, setHasInteractedCreate] = useState(false); + const [balanceLimit, setBalanceLimit] = useState(''); + const [balanceLimitReset, setBalanceLimitReset] = useState(''); + const [validityDate, setValidityDate] = useState(''); + const [hasInteractedTopup, setHasInteractedTopup] = useState(false); const [hasInteractedRecover, setHasInteractedRecover] = useState(false); @@ -166,13 +170,29 @@ export function LightningPaymentWorkflow({ setIsCreating(true); try { + const payload: { + amount_sats: number; + purpose: string; + balance_limit?: number; + balance_limit_reset?: string; + validity_date?: number; + } = { + amount_sats: amount, + purpose: 'create', + }; + + if (balanceLimit) payload.balance_limit = parseInt(balanceLimit); + if (balanceLimitReset) payload.balance_limit_reset = balanceLimitReset; + if (validityDate) { + payload.validity_date = Math.floor( + new Date(validityDate + 'T23:59:59').getTime() / 1000 + ); + } + const response = await fetch(`${baseUrl}/v1/balance/lightning/invoice`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - amount_sats: amount, - purpose: 'create', - }), + body: JSON.stringify(payload), }); if (!response.ok) { @@ -214,7 +234,7 @@ export function LightningPaymentWorkflow({ } finally { setIsCreating(false); } - }, [createAmount, baseUrl, pollInvoiceStatus, onApiKeyCreated]); + }, [createAmount, baseUrl, pollInvoiceStatus, onApiKeyCreated, balanceLimit, balanceLimitReset, validityDate]); const handleTopupInvoice = useCallback(async (): Promise => { const amount = parseInt(topupAmount); @@ -333,14 +353,13 @@ export function LightningPaymentWorkflow({ } }, [recoverInvoice, baseUrl, onApiKeyCreated]); - const showCreateDetails = - hasInteractedCreate || createAmount.trim().length > 0; const showTopupDetails = hasInteractedTopup || topupAmount.trim().length > 0 || topupApiKey.trim().length > 0; const showRecoverDetails = hasInteractedRecover || recoverInvoice.trim().length > 0; + const showCreateDetails = createAmount.trim().length > 0; return ( @@ -367,9 +386,17 @@ export function LightningPaymentWorkflow({ onChange={(event) => setCreateAmount(event.target.value)} placeholder='Amount in sats (e.g., 1000)' className='text-sm' - onFocus={() => setHasInteractedCreate(true)} /> - {showCreateDetails && ( +
+ +
- )} +