Compare commits

...

4 Commits

Author SHA1 Message Date
redshift
aced8737f5 Add comprehensive documentation for withdrawal fix 2025-10-17 04:07:14 +00:00
redshift
27de0921ae 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
2025-10-17 04:06:35 +00:00
redshift
a48d8f102c Add migration for pending_withdrawals table 2025-10-17 04:03:46 +00:00
redshift
7cb6dfa774 Add PendingWithdrawal model for token persistence 2025-10-17 04:03:35 +00:00
4 changed files with 432 additions and 12 deletions

134
WITHDRAWAL_FIX.md Normal file
View File

@@ -0,0 +1,134 @@
# Withdrawal Token Persistence Fix
This document describes the fix for the admin panel withdrawal feature that was causing coin loss due to non-persistent cashu tokens.
## Problem
The original admin panel withdrawal feature had a critical issue:
- Generated cashu tokens were only displayed momentarily in the UI
- If the modal was closed accidentally, the token was lost forever
- No record of pending withdrawals was maintained
- This resulted in actual coin loss for administrators
## Solution
### 1. Token Persistence
- Added `PendingWithdrawal` database model to store all withdrawal tokens
- Every withdrawal is now automatically saved to the database
- Tokens are never lost, even if the UI is closed accidentally
### 2. Pending Withdrawals Display
- New "Pending Withdrawals" section in the admin panel
- Shows all unclaimed withdrawal tokens with details:
- Amount and currency unit
- Mint URL
- Creation timestamp
- Auto-send status
- Lightning address (if auto-sent)
- Easy copy-to-clipboard functionality for tokens
- Ability to mark withdrawals as claimed when used
### 3. Automatic Lightning Address Sending
- Optional auto-send feature for withdrawals
- If `RECEIVE_LN_ADDRESS` is configured and auto-send is enabled:
- Tokens are automatically sent to the Lightning address
- No manual token handling required
- Withdrawal is marked as completed automatically
- Fallback to manual token storage if auto-send fails
### 4. Enhanced UI/UX
- Checkbox option to enable auto-send during withdrawal
- Better feedback messages for successful operations
- Real-time updates of pending withdrawals list
- Improved error handling and user notifications
## Database Changes
### New Table: `pending_withdrawals`
```sql
CREATE TABLE pending_withdrawals (
id INTEGER PRIMARY KEY,
token TEXT NOT NULL,
amount INTEGER NOT NULL,
unit TEXT NOT NULL,
mint_url TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
claimed BOOLEAN NOT NULL DEFAULT FALSE,
auto_sent BOOLEAN NOT NULL DEFAULT FALSE,
ln_address TEXT,
notes TEXT
);
```
## API Changes
### Enhanced `/admin/withdraw` endpoint
- New optional parameter: `auto_send_to_ln: bool`
- Returns additional fields:
- `withdrawal_id`: Database ID of the stored withdrawal
- `auto_sent`: Whether the token was automatically sent
- `ln_address`: Lightning address used (if auto-sent)
- `amount_received`: Amount received by Lightning address
### New endpoint: `/admin/api/mark-withdrawal-claimed/{withdrawal_id}`
- POST endpoint to mark a withdrawal as claimed
- Prevents accidental reuse of tokens
- Helps track withdrawal status
### New partial: `/admin/partials/pending-withdrawals`
- HTMX-powered component for displaying pending withdrawals
- Auto-refreshes when new withdrawals are created
- Provides interactive token management
## Configuration
### Environment Variables
The fix uses existing configuration:
- `RECEIVE_LN_ADDRESS`: Lightning address for auto-send functionality
- `ADMIN_PASSWORD`: Required for admin panel access
### Settings
Auto-send functionality respects the existing `receive_ln_address` setting that can be configured through the admin panel settings interface.
## Usage
### Manual Withdrawal (Default)
1. Click "💸 Withdraw Balance" in admin panel
2. Select mint and currency
3. Enter withdrawal amount
4. Click "Withdraw"
5. Token is generated and stored in pending withdrawals
6. Copy token from the result or from pending withdrawals list
7. Use token in your cashu wallet
8. Mark as claimed when used (optional)
### Auto-Send Withdrawal
1. Ensure `RECEIVE_LN_ADDRESS` is configured in settings
2. Click "💸 Withdraw Balance" in admin panel
3. Select mint and currency
4. Enter withdrawal amount
5. Check "Auto-send to Lightning address" checkbox
6. Click "Withdraw"
7. Funds are automatically sent to your Lightning address
8. Withdrawal is marked as completed automatically
## Migration
The database migration `123abc456def_add_pending_withdrawals_table.py` will be automatically applied when the application starts, creating the new `pending_withdrawals` table.
## Benefits
1. **No More Coin Loss**: All withdrawal tokens are permanently stored
2. **Better UX**: Clear visibility of all pending withdrawals
3. **Automation**: Optional auto-send reduces manual token handling
4. **Audit Trail**: Complete history of all withdrawals
5. **Recovery**: Ability to retrieve tokens even after UI accidents
6. **Flexibility**: Choice between manual tokens and automatic Lightning sends
## Backward Compatibility
This fix is fully backward compatible:
- Existing withdrawal functionality continues to work
- No breaking changes to existing APIs
- New features are opt-in
- Database migration is automatic and safe

View File

@@ -0,0 +1,40 @@
"""Add pending withdrawals table
Revision ID: 123abc456def
Revises: 898f00ea481e
Create Date: 2025-10-17 04:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import sqlite
# revision identifiers, used by Alembic.
revision = '123abc456def'
down_revision = '898f00ea481e'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('pending_withdrawals',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('token', sa.String(), nullable=False),
sa.Column('amount', sa.Integer(), nullable=False),
sa.Column('unit', sa.String(), nullable=False),
sa.Column('mint_url', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('claimed', sa.Boolean(), nullable=False),
sa.Column('auto_sent', sa.Boolean(), nullable=False),
sa.Column('ln_address', sa.String(), nullable=True),
sa.Column('notes', sa.String(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('pending_withdrawals')
# ### end Alembic commands ###

View File

@@ -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 """
<h2>Pending Withdrawals</h2>
<p style="color: #718096; font-style: italic;">No pending withdrawals</p>
"""
rows = "".join(
[
f"""<tr>
<td>{withdrawal.amount} {withdrawal.unit.upper()}</td>
<td>{withdrawal.mint_url.replace("https://", "").replace("http://", "")}</td>
<td>{withdrawal.created_at.strftime('%Y-%m-%d %H:%M:%S')} UTC</td>
<td>{"✅ Auto-sent" if withdrawal.auto_sent else "⏳ Manual"}</td>
<td>{withdrawal.ln_address or "-"}</td>
<td>
<button class="copy-token-btn" onclick="copyWithdrawalToken('{withdrawal.token}')">📋 Copy Token</button>
<button class="claim-btn" onclick="markWithdrawalClaimed({withdrawal.id})">✅ Mark Claimed</button>
</td>
</tr>"""
for withdrawal in pending_withdrawals
]
)
return f"""
<h2>Pending Withdrawals</h2>
<p style="margin-bottom: 1rem; font-size: 0.9rem; color: #718096;">
These are cashu tokens that have been generated but not yet claimed.
Copy the token to use it, or mark as claimed when used.
</p>
<table>
<tr>
<th>Amount</th>
<th>Mint</th>
<th>Created</th>
<th>Status</th>
<th>LN Address</th>
<th>Actions</th>
</tr>
{rows}
</table>
"""
@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:
<div id="withdraw-warning" class="warning" style="display: none;">
⚠️ Warning: Withdrawing more than your balance will use user funds!
</div>
<div style="margin: 10px 0;">
<label>
<input type="checkbox" id="auto-send-ln">
Auto-send to Lightning address (if configured)
</label>
</div>
<button id="confirm-withdraw-btn" onclick="performWithdraw()">💸 Withdraw</button>
<button onclick="closeWithdrawModal()" style="background-color: #718096;">Cancel</button>
</div>
@@ -580,7 +716,15 @@ async def dashboard(request: Request) -> str:
<strong>Withdrawal Token:</strong>
<div id="token-text"></div>
<button id="copy-btn" class="copy-btn" onclick="copyToken()">Copy Token</button>
<p><em>Save this token! It represents your withdrawn balance.</em></p>
<p><em>Save this token! It represents your withdrawn balance and is now stored in pending withdrawals below.</em></p>
</div>
<div id="pending-withdrawals-container"
hx-get="/admin/partials/pending-withdrawals"
hx-trigger="load, refresh"
hx-swap="innerHTML">
<h2>Pending Withdrawals</h2>
<div style="color:#718096;">Loading pending withdrawals…</div>
</div>
<div id="apikeys-table"
@@ -720,7 +864,7 @@ async def view_logs(request: Request, request_id: str) -> 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; }
"""
"""

View File

@@ -1,6 +1,7 @@
import os
from contextlib import asynccontextmanager
from typing import AsyncGenerator
from datetime import datetime, timezone
from alembic import command
from alembic.config import Config
@@ -66,6 +67,21 @@ class ModelRow(SQLModel, table=True): # type: ignore
top_provider: str | None = Field(default=None)
class PendingWithdrawal(SQLModel, table=True): # type: ignore
__tablename__ = "pending_withdrawals"
id: int = Field(primary_key=True)
token: str = Field(description="The cashu token string")
amount: int = Field(description="Amount in the token's unit")
unit: str = Field(description="Currency unit (sat, msat)")
mint_url: str = Field(description="Mint URL where the token was created")
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
claimed: bool = Field(default=False, description="Whether the token has been claimed/used")
auto_sent: bool = Field(default=False, description="Whether the token was automatically sent to LN address")
ln_address: str | None = Field(default=None, description="Lightning address where token was sent")
notes: str | None = Field(default=None, description="Optional notes about the withdrawal")
async def balances_for_mint_and_unit(
db_session: AsyncSession, mint_url: str, unit: str
) -> int:
@@ -126,4 +142,4 @@ def run_migrations() -> None:
"Database migration failed",
extra={"error": str(e), "error_type": type(e).__name__},
)
raise
raise