From 27de0921ae10644ca75539a1a1de20be1e4655be Mon Sep 17 00:00:00 2001
From: redshift <213178690+sh1ftred@users.noreply.github.com>
Date: Fri, 17 Oct 2025 04:06:35 +0000
Subject: [PATCH] Implement token persistence and enhanced withdrawal
functionality
- Add PendingWithdrawal model to persist withdrawal tokens
- Add pending withdrawals display in admin panel
- Add auto-send to Lightning address option
- Add ability to mark withdrawals as claimed
- Prevent token loss by storing all withdrawals in database
- Enhanced UI with better feedback and token management
---
routstr/core/admin.py | 252 ++++++++++++++++++++++++++++++++++++++++--
1 file changed, 241 insertions(+), 11 deletions(-)
diff --git a/routstr/core/admin.py b/routstr/core/admin.py
index b52fdde6..cf8875ee 100644
--- a/routstr/core/admin.py
+++ b/routstr/core/admin.py
@@ -6,7 +6,7 @@ from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
-from sqlmodel import select
+from sqlmodel import select, desc
from ..wallet import (
fetch_all_balances,
@@ -14,8 +14,9 @@ from ..wallet import (
get_wallet,
send_token,
slow_filter_spend_proofs,
+ send_to_lnurl,
)
-from .db import ApiKey, create_session
+from .db import ApiKey, PendingWithdrawal, create_session
from .logging import get_logger
from .settings import SettingsService, settings
@@ -127,6 +128,64 @@ async def partial_apikeys(request: Request) -> str:
"""
+@admin_router.get(
+ "/partials/pending-withdrawals",
+ dependencies=[Depends(require_admin_api)],
+ response_class=HTMLResponse,
+)
+async def partial_pending_withdrawals(request: Request) -> str:
+ async with create_session() as session:
+ result = await session.exec(
+ select(PendingWithdrawal)
+ .where(PendingWithdrawal.claimed == False)
+ .order_by(desc(PendingWithdrawal.created_at))
+ .limit(20)
+ )
+ pending_withdrawals = result.all()
+
+ if not pending_withdrawals:
+ return """
+
Pending Withdrawals
+ No pending withdrawals
+ """
+
+ rows = "".join(
+ [
+ f"""
+ | {withdrawal.amount} {withdrawal.unit.upper()} |
+ {withdrawal.mint_url.replace("https://", "").replace("http://", "")} |
+ {withdrawal.created_at.strftime('%Y-%m-%d %H:%M:%S')} UTC |
+ {"✅ Auto-sent" if withdrawal.auto_sent else "⏳ Manual"} |
+ {withdrawal.ln_address or "-"} |
+
+
+
+ |
+
"""
+ for withdrawal in pending_withdrawals
+ ]
+ )
+
+ return f"""
+ Pending Withdrawals
+
+ These are cashu tokens that have been generated but not yet claimed.
+ Copy the token to use it, or mark as claimed when used.
+
+
+
+ | Amount |
+ Mint |
+ Created |
+ Status |
+ LN Address |
+ Actions |
+
+ {rows}
+
+ """
+
+
@admin_router.get("/api/balances", dependencies=[Depends(require_admin_api)])
async def get_balances_api(request: Request) -> list[dict[str, object]]:
balance_details, _tw, _tu, _ow = await fetch_all_balances()
@@ -167,6 +226,30 @@ class WithdrawRequest(BaseModel):
amount: int
mint_url: str | None = None
unit: str = "sat"
+ auto_send_to_ln: bool = False
+
+
+@admin_router.post("/api/mark-withdrawal-claimed/{withdrawal_id}", dependencies=[Depends(require_admin_api)])
+async def mark_withdrawal_claimed(withdrawal_id: int) -> dict[str, str]:
+ async with create_session() as session:
+ result = await session.exec(
+ select(PendingWithdrawal).where(PendingWithdrawal.id == withdrawal_id)
+ )
+ withdrawal = result.first()
+
+ if not withdrawal:
+ raise HTTPException(status_code=404, detail="Withdrawal not found")
+
+ withdrawal.claimed = True
+ session.add(withdrawal)
+ await session.commit()
+
+ logger.info(
+ "Withdrawal marked as claimed",
+ extra={"withdrawal_id": withdrawal_id, "amount": withdrawal.amount, "unit": withdrawal.unit}
+ )
+
+ return {"status": "success", "message": "Withdrawal marked as claimed"}
def login_form() -> str:
@@ -325,6 +408,7 @@ async def dashboard(request: Request) -> str:
const amount = parseInt(document.getElementById('withdraw-amount').value);
const select = document.getElementById('mint-unit-select');
const selectedValue = select.value;
+ const autoSendCheckbox = document.getElementById('auto-send-ln');
const button = document.getElementById('confirm-withdraw-btn');
const tokenResult = document.getElementById('token-result');
@@ -359,15 +443,23 @@ async def dashboard(request: Request) -> str:
body: JSON.stringify({
amount: amount,
mint_url: mint,
- unit: unit
+ unit: unit,
+ auto_send_to_ln: autoSendCheckbox.checked
})
});
if (response.ok) {
const data = await response.json();
- document.getElementById('token-text').textContent = data.token;
- tokenResult.style.display = 'block';
- closeWithdrawModal();
+ if (data.auto_sent) {
+ alert(`Successfully sent ${amount} ${unit} to ${data.ln_address}!`);
+ closeWithdrawModal();
+ refreshPendingWithdrawals();
+ } else {
+ document.getElementById('token-text').textContent = data.token;
+ tokenResult.style.display = 'block';
+ closeWithdrawModal();
+ refreshPendingWithdrawals();
+ }
} else {
const errorData = await response.json();
alert('Failed to withdraw balance: ' + (errorData.detail || 'Unknown error'));
@@ -394,6 +486,44 @@ async def dashboard(request: Request) -> str:
});
}
+ function copyWithdrawalToken(token) {
+ navigator.clipboard.writeText(token).then(() => {
+ alert('Token copied to clipboard!');
+ }).catch(err => {
+ alert('Failed to copy token');
+ });
+ }
+
+ async function markWithdrawalClaimed(withdrawalId) {
+ if (!confirm('Mark this withdrawal as claimed? This action cannot be undone.')) {
+ return;
+ }
+
+ try {
+ const response = await fetch(`/admin/api/mark-withdrawal-claimed/${withdrawalId}`, {
+ method: 'POST',
+ credentials: 'same-origin'
+ });
+
+ if (response.ok) {
+ alert('Withdrawal marked as claimed');
+ refreshPendingWithdrawals();
+ } else {
+ const errorData = await response.json();
+ alert('Failed to mark withdrawal as claimed: ' + (errorData.detail || 'Unknown error'));
+ }
+ } catch (error) {
+ alert('Error: ' + error.message);
+ }
+ }
+
+ function refreshPendingWithdrawals() {
+ const container = document.getElementById('pending-withdrawals-container');
+ if (container) {
+ htmx.trigger(container, 'refresh');
+ }
+ }
+
function refreshPage() {
window.location.reload();
}
@@ -546,6 +676,12 @@ async def dashboard(request: Request) -> str:
⚠️ Warning: Withdrawing more than your balance will use user funds!
+
+
+
@@ -580,7 +716,15 @@ async def dashboard(request: Request) -> str:
Withdrawal Token:
- Save this token! It represents your withdrawn balance.
+ Save this token! It represents your withdrawn balance and is now stored in pending withdrawals below.
+
+
+
+
Pending Withdrawals
+
Loading pending withdrawals…
str:
@admin_router.post("/withdraw", dependencies=[Depends(require_admin_api)])
async def withdraw(
request: Request, withdraw_request: WithdrawRequest
-) -> dict[str, str]:
+) -> dict[str, object]:
# Get wallet and check balance
from .settings import settings as global_settings
@@ -744,10 +888,91 @@ async def withdraw(
if withdraw_request.amount > current_balance:
raise HTTPException(status_code=400, detail="Insufficient wallet balance")
+ # Generate the token
token = await send_token(
withdraw_request.amount, withdraw_request.unit, withdraw_request.mint_url
)
- return {"token": token}
+
+ # Store the withdrawal in the database
+ async with create_session() as session:
+ pending_withdrawal = PendingWithdrawal(
+ token=token,
+ amount=withdraw_request.amount,
+ unit=withdraw_request.unit,
+ mint_url=withdraw_request.mint_url or global_settings.primary_mint,
+ claimed=False,
+ auto_sent=False,
+ ln_address=None,
+ notes=None
+ )
+
+ # If auto-send is requested and LN address is configured, try to send
+ if withdraw_request.auto_send_to_ln and global_settings.receive_ln_address:
+ try:
+ amount_received = await send_to_lnurl(
+ withdraw_request.amount,
+ withdraw_request.unit,
+ withdraw_request.mint_url or global_settings.primary_mint,
+ global_settings.receive_ln_address
+ )
+
+ pending_withdrawal.auto_sent = True
+ pending_withdrawal.ln_address = global_settings.receive_ln_address
+ pending_withdrawal.claimed = True
+ pending_withdrawal.notes = f"Auto-sent {amount_received} to {global_settings.receive_ln_address}"
+
+ session.add(pending_withdrawal)
+ await session.commit()
+
+ logger.info(
+ "Withdrawal auto-sent to Lightning address",
+ extra={
+ "amount": withdraw_request.amount,
+ "unit": withdraw_request.unit,
+ "ln_address": global_settings.receive_ln_address,
+ "amount_received": amount_received
+ }
+ )
+
+ return {
+ "auto_sent": True,
+ "ln_address": global_settings.receive_ln_address,
+ "amount_received": amount_received,
+ "withdrawal_id": pending_withdrawal.id
+ }
+
+ except Exception as e:
+ logger.error(
+ "Failed to auto-send withdrawal to Lightning address",
+ extra={
+ "error": str(e),
+ "ln_address": global_settings.receive_ln_address,
+ "amount": withdraw_request.amount,
+ "unit": withdraw_request.unit
+ }
+ )
+ # Continue with manual token storage if auto-send fails
+ pending_withdrawal.notes = f"Auto-send failed: {str(e)}"
+
+ session.add(pending_withdrawal)
+ await session.commit()
+
+ logger.info(
+ "Withdrawal token generated and stored",
+ extra={
+ "withdrawal_id": pending_withdrawal.id,
+ "amount": withdraw_request.amount,
+ "unit": withdraw_request.unit,
+ "mint_url": withdraw_request.mint_url or global_settings.primary_mint,
+ "auto_send_requested": withdraw_request.auto_send_to_ln
+ }
+ )
+
+ return {
+ "token": token,
+ "withdrawal_id": pending_withdrawal.id,
+ "auto_sent": False
+ }
DASHBOARD_CSS: str = """
@@ -767,6 +992,10 @@ button:disabled { background: #a0aec0; cursor: not-allowed; transform: none; }
.refresh-btn { background: #48bb78; }
.refresh-btn:hover { background: #38a169; }
.investigate-btn { background: #4299e1; }
+.copy-token-btn { background: #38a169; padding: 6px 12px; font-size: 12px; margin-right: 5px; }
+.copy-token-btn:hover { background: #2f855a; }
+.claim-btn { background: #e53e3e; padding: 6px 12px; font-size: 12px; }
+.claim-btn:hover { background: #c53030; }
.balance-card { background: white; padding: 2rem; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 2rem; }
.balance-item { display: flex; justify-content: space-between; margin-bottom: 1rem; }
.balance-label { color: #718096; }
@@ -789,7 +1018,8 @@ button:disabled { background: #a0aec0; cursor: not-allowed; transform: none; }
@keyframes slideIn { from { transform: translateY(-20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
.close { color: #a0aec0; float: right; font-size: 28px; font-weight: bold; cursor: pointer; margin: -10px -10px 0 0; }
.close:hover { color: #2d3748; }
-input[type="number"], input[type="text"], select { width: 100%; padding: 10px; margin: 10px 0; border: 2px solid #e2e8f0; border-radius: 6px; font-size: 16px; transition: border 0.2s; }
+input[type="number"], input[type="text"], input[type="checkbox"], select { width: 100%; padding: 10px; margin: 10px 0; border: 2px solid #e2e8f0; border-radius: 6px; font-size: 16px; transition: border 0.2s; }
+input[type="checkbox"] { width: auto; margin-right: 8px; }
input[type="number"]:focus, input[type="text"]:focus, select:focus { outline: none; border-color: #4299e1; }
.warning { color: #e53e3e; font-weight: 600; margin: 10px 0; padding: 10px; background: #fff5f5; border-radius: 6px; }
"""
@@ -813,4 +1043,4 @@ h1 { color: #333; }
.log-field { margin: 2px 0; color: #666; word-break: break-all; }
.no-logs { text-align: center; color: #666; padding: 40px; }
.request-id-display { background-color: #e9ecef; padding: 10px; border-radius: 4px; margin-bottom: 20px; font-family: monospace; }
-"""
+"""
\ No newline at end of file