mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 12:28:22 +00:00
191 lines
6.4 KiB
Python
191 lines
6.4 KiB
Python
import asyncio
|
|
import os
|
|
import time
|
|
|
|
from sixty_nuts import Wallet
|
|
from sqlmodel import col, func, select, update
|
|
|
|
from .db import ApiKey, AsyncSession, get_session
|
|
|
|
RECEIVE_LN_ADDRESS = os.environ["RECEIVE_LN_ADDRESS"]
|
|
MINT = os.environ.get("MINT", "https://mint.minibits.cash/Bitcoin")
|
|
MINIMUM_PAYOUT = int(os.environ.get("MINIMUM_PAYOUT", 100))
|
|
REFUND_PROCESSING_INTERVAL = int(os.environ.get("REFUND_PROCESSING_INTERVAL", 3600))
|
|
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 delete_key_if_zero_balance(key: ApiKey, session: AsyncSession) -> None:
|
|
"""Delete the given API key if its balance is zero."""
|
|
if key.balance == 0:
|
|
await session.delete(key)
|
|
await session.commit()
|
|
|
|
|
|
async def init_wallet() -> None:
|
|
global WALLET
|
|
WALLET = await Wallet.create(nsec=NSEC, mint_urls=[MINT])
|
|
|
|
|
|
async def close_wallet() -> None:
|
|
global WALLET
|
|
await WALLET.aclose()
|
|
|
|
|
|
async def pay_out() -> None:
|
|
"""
|
|
Calculates the pay-out amount based on the spent balance, profit, and donation rate.
|
|
"""
|
|
try:
|
|
from .db import create_session
|
|
|
|
async with create_session() as session:
|
|
result = await session.exec(
|
|
select(func.sum(col(ApiKey.balance))).where(ApiKey.balance > 0)
|
|
)
|
|
balance = result.one_or_none()
|
|
if not balance:
|
|
# No balance to pay out - this is OK, not an error
|
|
return
|
|
|
|
user_balance_sats = balance // 1000
|
|
state = await WALLET.fetch_wallet_state()
|
|
wallet_balance_sats = state.balance
|
|
|
|
# Handle edge cases more gracefully
|
|
if wallet_balance_sats < user_balance_sats:
|
|
print(
|
|
f"Warning: Wallet balance ({wallet_balance_sats} sats) is less than user balance ({user_balance_sats} sats). Skipping payout."
|
|
)
|
|
return
|
|
|
|
if (revenue := wallet_balance_sats - user_balance_sats) <= MINIMUM_PAYOUT:
|
|
# Not enough revenue yet - this is OK
|
|
return
|
|
|
|
devs_donation = int(revenue * DEVS_DONATION_RATE)
|
|
owners_draw = revenue - devs_donation
|
|
|
|
# Send payouts
|
|
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
|
|
print(f"Error in pay_out: {e}")
|
|
|
|
|
|
async def credit_balance(cashu_token: str, key: ApiKey, session: AsyncSession) -> int:
|
|
"""Redeem a Cashu token and credit the amount to the API key balance."""
|
|
try:
|
|
amount_sats = await WALLET.redeem(cashu_token)
|
|
except Exception:
|
|
# Ensure the balance cannot become negative if redeem fails
|
|
return 0
|
|
|
|
if amount_sats <= 0:
|
|
return 0
|
|
|
|
amount_msats = amount_sats * 1000
|
|
key.balance += amount_msats
|
|
|
|
session.add(key)
|
|
await session.flush()
|
|
|
|
# Apply the balance change atomically to avoid race conditions when topping
|
|
# up the same key concurrently.
|
|
stmt = (
|
|
update(ApiKey)
|
|
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
|
.values(balance=col(ApiKey.balance) + amount_msats)
|
|
)
|
|
await session.exec(stmt) # type: ignore[call-overload]
|
|
await session.commit()
|
|
|
|
return amount_msats
|
|
|
|
|
|
async def check_for_refunds() -> None:
|
|
"""
|
|
Periodically checks for API keys that are eligible for refunds and processes them.
|
|
|
|
Raises:
|
|
Exception: If an error occurs during the refund check process.
|
|
"""
|
|
# Setting REFUND_PROCESSING_INTERVAL to 0 disables it
|
|
if REFUND_PROCESSING_INTERVAL == 0:
|
|
print("Automatic refund processing is disabled.")
|
|
return
|
|
|
|
while True:
|
|
try:
|
|
async for session in get_session():
|
|
result = await session.exec(select(ApiKey))
|
|
keys = result.all()
|
|
current_time = int(time.time())
|
|
for key in keys:
|
|
if (
|
|
key.balance > 0
|
|
and key.refund_address
|
|
and key.key_expiry_time
|
|
and key.key_expiry_time < current_time
|
|
):
|
|
print(
|
|
f" DEBUG Refunding key {key.hashed_key[:3] + '[...]' + key.hashed_key[-3:]}, Current Time: {current_time}, Expirary Time: {key.key_expiry_time}",
|
|
flush=True,
|
|
)
|
|
await refund_balance(key.balance, key, session)
|
|
await delete_key_if_zero_balance(key, session)
|
|
|
|
# Sleep for the specified interval before checking again
|
|
await asyncio.sleep(REFUND_PROCESSING_INTERVAL)
|
|
except asyncio.CancelledError:
|
|
break
|
|
except Exception as e:
|
|
print(f"Error during refund check: {e}")
|
|
|
|
|
|
async def refund_balance(amount_msats: int, key: ApiKey, session: AsyncSession) -> int:
|
|
if amount_msats <= 0:
|
|
amount_msats = key.balance
|
|
|
|
# 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)")
|
|
|
|
# Atomically deduct the balance to avoid race conditions when multiple
|
|
# refunds are triggered concurrently.
|
|
stmt = (
|
|
update(ApiKey)
|
|
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
|
.where(col(ApiKey.balance) >= amount_msats)
|
|
.values(balance=col(ApiKey.balance) - amount_msats)
|
|
)
|
|
result = await session.exec(stmt) # type: ignore[call-overload]
|
|
await session.commit()
|
|
if result.rowcount == 0:
|
|
raise ValueError("Insufficient balance.")
|
|
await session.refresh(key)
|
|
await delete_key_if_zero_balance(key, session)
|
|
|
|
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:
|
|
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
|