diff --git a/router/admin.py b/router/admin.py index 698568b2..8881551a 100644 --- a/router/admin.py +++ b/router/admin.py @@ -3,11 +3,10 @@ from datetime import datetime, timezone from fastapi import APIRouter, Request from fastapi.responses import HTMLResponse -from sixty_nuts import Wallet from sqlmodel import select from .db import ApiKey, create_session -from .cashu import NSEC, MINT +from .cashu import WALLET admin_router = APIRouter(prefix="/admin") @@ -112,9 +111,8 @@ async def dashboard(request: Request) -> str: # Calculate the total balance of all API keys total_user_balance = int(sum(key.balance / 1000 for key in api_keys)) # Fetch balance from cashu - async with Wallet(nsec=NSEC, mint_urls=[MINT]) as wallet: - current_balance = (await wallet.fetch_wallet_state()).balance - owner_balance = current_balance - total_user_balance + current_balance = (await WALLET.fetch_wallet_state()).balance + owner_balance = current_balance - total_user_balance return f""" diff --git a/router/cashu.py b/router/cashu.py index 213af00a..c56437bc 100644 --- a/router/cashu.py +++ b/router/cashu.py @@ -15,6 +15,15 @@ DEV_LN_ADDRESS = "routstr@minibits.cash" DEVS_DONATION_RATE = float(os.environ.get("DEVS_DONATION_RATE", 0.021)) # 2.1% NSEC = os.environ["NSEC"] # Nostr private key for the wallet +WALLET = Wallet(nsec=NSEC, mint_urls=[MINT]) + +async def init_wallet(): + global WALLET + WALLET = await Wallet.create(nsec=NSEC, mint_urls=[MINT]) + +async def close_wallet(): + global WALLET + await WALLET.aclose() async def pay_out() -> None: """ @@ -34,9 +43,8 @@ async def pay_out() -> None: return user_balance_sats = balance // 1000 - async with Wallet(nsec=NSEC, mint_urls=[MINT]) as wallet: - state = await wallet.fetch_wallet_state() - wallet_balance_sats = state.balance + state = await WALLET.fetch_wallet_state() + wallet_balance_sats = state.balance # Handle edge cases more gracefully if wallet_balance_sats < user_balance_sats: @@ -53,9 +61,8 @@ async def pay_out() -> None: owners_draw = revenue - devs_donation # Send payouts - async with Wallet(nsec=NSEC, mint_urls=[MINT]) as wallet: - await wallet.send_to_lnurl(RECEIVE_LN_ADDRESS, owners_draw) - await wallet.send_to_lnurl(DEV_LN_ADDRESS, devs_donation) + await WALLET.send_to_lnurl(RECEIVE_LN_ADDRESS, owners_draw) + await WALLET.send_to_lnurl(DEV_LN_ADDRESS, devs_donation) except Exception as e: # Log the error but don't crash - payouts can be retried later @@ -63,15 +70,14 @@ async def pay_out() -> None: async def credit_balance(cashu_token: str, key: ApiKey, session: AsyncSession) -> int: - async with Wallet(nsec=NSEC, mint_urls=[MINT]) as wallet: - state_before = await wallet.fetch_wallet_state() - await wallet.redeem(cashu_token) - state_after = await wallet.fetch_wallet_state() - amount = (state_after.balance - state_before.balance) * 1000 - key.balance += amount - session.add(key) - await session.commit() - return amount + state_before = await WALLET.fetch_wallet_state() + await WALLET.redeem(cashu_token) + state_after = await WALLET.fetch_wallet_state() + amount = (state_after.balance - state_before.balance) * 1000 + key.balance += amount + session.add(key) + await session.commit() + return amount async def check_for_refunds() -> None: @@ -81,7 +87,6 @@ async def check_for_refunds() -> None: Raises: Exception: If an error occurs during the refund check process. """ - raise Exception("TODO migrate to sixty-nuts") # Setting REFUND_PROCESSING_INTERVAL to 0 disables it if REFUND_PROCESSING_INTERVAL == 0: print("Automatic refund processing is disabled.") @@ -112,31 +117,34 @@ async def check_for_refunds() -> None: print(f"Error during refund check: {e}") -async def refund_balance(amount: int, key: ApiKey, session: AsyncSession) -> int: - async with Wallet(nsec=NSEC, mint_urls=[MINT]) as wallet: - if key.balance < amount: - raise ValueError("Insufficient balance.") - if amount <= 0: - amount = key.balance +async def refund_balance(amount_msats: int, key: ApiKey, session: AsyncSession) -> int: + if key.balance < amount_msats: + raise ValueError("Insufficient balance.") + if amount_msats <= 0: + amount_msats = key.balance - key.balance -= amount - session.add(key) - await session.commit() + # Convert msats to sats for cashu wallet + amount_sats = amount_msats // 1000 + if amount_sats == 0: + raise ValueError("Amount too small to refund (less than 1 sat)") - if key.refund_address is None: - raise ValueError("Refund address not set.") + key.balance -= amount_msats + session.add(key) + await session.commit() - return await wallet.send_to_lnurl( - key.refund_address, - amount=amount, - ) + if key.refund_address is None: + raise ValueError("Refund address not set.") + + return await WALLET.send_to_lnurl( + key.refund_address, + amount=amount_sats, + ) async def redeem(cashu_token: str, lnurl: str) -> int: - async with Wallet(nsec=NSEC, mint_urls=[MINT]) as wallet: - state_before = await wallet.fetch_wallet_state() - await wallet.redeem(cashu_token) - state_after = await wallet.fetch_wallet_state() - amount = state_after.balance - state_before.balance - await wallet.send_to_lnurl(lnurl, amount=amount) - return amount + state_before = await WALLET.fetch_wallet_state() + await WALLET.redeem(cashu_token) + state_after = await WALLET.fetch_wallet_state() + amount = state_after.balance - state_before.balance + await WALLET.send_to_lnurl(lnurl, amount=amount) + return amount diff --git a/router/main.py b/router/main.py index d266fd6f..72772b2a 100644 --- a/router/main.py +++ b/router/main.py @@ -1,4 +1,5 @@ import asyncio +from contextlib import asynccontextmanager import os from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware @@ -6,19 +7,31 @@ from fastapi.middleware.cors import CORSMiddleware from .db import init_db from .admin import admin_router from .proxy import proxy_router -from .account import account_router +from .account import wallet_router from .models import MODELS, update_sats_pricing -from .cashu import check_for_refunds +from .cashu import check_for_refunds, init_wallet, close_wallet from .discovery import providers_router - __version__ = "0.0.1" +@asynccontextmanager +async def lifespan(_: FastAPI): + await init_db() + await init_wallet() + asyncio.create_task(update_sats_pricing()) + asyncio.create_task(check_for_refunds()) + + yield + + await close_wallet() + + app = FastAPI( version=__version__, title=os.environ.get("NAME", "ARoutstrNode" + __version__), description=os.environ.get("DESCRIPTION", "A Routstr Node"), contact={"name": os.environ.get("NAME", ""), "npub": os.environ.get("NPUB", "")}, + lifespan=lifespan, ) # Configure CORS @@ -46,13 +59,6 @@ async def info(): app.include_router(admin_router) -app.include_router(account_router) +app.include_router(wallet_router) app.include_router(providers_router) app.include_router(proxy_router) - - -@app.on_event("startup") -async def startup_event(): - await init_db() - asyncio.create_task(update_sats_pricing()) - asyncio.create_task(check_for_refunds()) diff --git a/tests/test_account.py b/tests/test_account.py index 4cdcfe8f..164daff3 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -116,17 +116,10 @@ async def test_refund_balance_without_address( test_session.add(key) await test_session.commit() - # Mock the Wallet class at the router.account module level - with patch("router.account.Wallet") as mock_wallet_class: - # Create a mock wallet instance - mock_wallet = AsyncMock() - mock_wallet.__aenter__ = AsyncMock(return_value=mock_wallet) - mock_wallet.__aexit__ = AsyncMock(return_value=None) + # Mock the WALLET instance at the router.account module level + with patch("router.account.WALLET") as mock_wallet: mock_wallet.send = AsyncMock(return_value="cashuBqQSEQ...") - # Make the Wallet class return our mock when instantiated - mock_wallet_class.return_value = mock_wallet - response = await async_client.post( "/v1/wallet/refund", headers={"Authorization": f"Bearer sk-{api_key}"} ) @@ -138,8 +131,8 @@ async def test_refund_balance_without_address( assert data["msats"] == 500000 assert data["token"] == "cashuBqQSEQ..." - # Verify wallet.send was called with the correct amount - mock_wallet.send.assert_called_once_with(500000) + # Verify wallet.send was called with the correct amount (msats converted to sats) + mock_wallet.send.assert_called_once_with(500) @pytest.mark.asyncio