import asyncio import math from typing import TypedDict from cashu.core.base import Proof, Token from cashu.wallet.helpers import deserialize_token_from_string from cashu.wallet.wallet import Wallet from sqlmodel import col, update from .core import db, get_logger from .core.settings import settings from .payment.lnurl import raw_send_to_lnurl logger = get_logger(__name__) async def get_balance(unit: str) -> int: wallet = await get_wallet(settings.primary_mint, unit) return wallet.available_balance.amount async def recieve_token( token: str, ) -> tuple[int, str, str]: # amount, unit, mint_url token_obj = deserialize_token_from_string(token) if len(token_obj.keysets) > 1: raise ValueError("Multiple keysets per token currently not supported") wallet = await get_wallet(token_obj.mint, token_obj.unit, load=False) wallet.keyset_id = token_obj.keysets[0] if token_obj.mint not in settings.cashu_mints: return await swap_to_primary_mint(token_obj, wallet) wallet.verify_proofs_dleq(token_obj.proofs) await wallet.split(proofs=token_obj.proofs, amount=0, include_fees=True) return token_obj.amount, token_obj.unit, token_obj.mint async def send(amount: int, unit: str, mint_url: str | None = None) -> tuple[int, str]: """Internal send function - returns amount and serialized token""" wallet: Wallet = await get_wallet(mint_url or settings.primary_mint, unit) proofs = get_proofs_per_mint_and_unit( wallet, mint_url or settings.primary_mint, unit ) send_proofs, _ = await wallet.select_to_send( proofs, amount, set_reserved=True, include_fees=False ) token = await wallet.serialize_proofs( send_proofs, include_dleq=False, legacy=False, memo=None ) return amount, token async def send_token(amount: int, unit: str, mint_url: str | None = None) -> str: _, token = await send(amount, unit, mint_url) return token async def swap_to_primary_mint( token_obj: Token, token_wallet: Wallet ) -> tuple[int, str, str]: logger.info( "swap_to_primary_mint", extra={ "mint": token_obj.mint, "amount": token_obj.amount, "unit": token_obj.unit, }, ) # Ensure amount is an integer if not isinstance(token_obj.amount, int): token_amount = int(token_obj.amount) else: token_amount = token_obj.amount if token_obj.unit == "sat": amount_msat = token_amount * 1000 elif token_obj.unit == "msat": amount_msat = token_amount else: raise ValueError("Invalid unit") estimated_fee_sat = math.ceil(max(amount_msat // 1000 * 0.01, 2)) amount_msat_after_fee = amount_msat - estimated_fee_sat * 1000 primary_wallet = await get_wallet(settings.primary_mint, settings.primary_mint_unit) if settings.primary_mint_unit == "sat": minted_amount = int(amount_msat_after_fee // 1000) else: minted_amount = int(amount_msat_after_fee) mint_quote = await primary_wallet.request_mint(minted_amount) melt_quote = await token_wallet.melt_quote(mint_quote.request) _ = await token_wallet.melt( proofs=token_obj.proofs, invoice=mint_quote.request, fee_reserve_sat=melt_quote.fee_reserve, quote_id=melt_quote.quote, ) _ = await primary_wallet.mint(minted_amount, quote_id=mint_quote.quote) return int(minted_amount), settings.primary_mint_unit, settings.primary_mint async def credit_balance( cashu_token: str, key: db.ApiKey, session: db.AsyncSession ) -> int: logger.info( "credit_balance: Starting token redemption", extra={"token_preview": cashu_token[:50]}, ) try: amount, unit, mint_url = await recieve_token(cashu_token) logger.info( "credit_balance: Token redeemed successfully", extra={"amount": amount, "unit": unit, "mint_url": mint_url}, ) if unit == "sat": amount = amount * 1000 logger.info( "credit_balance: Converted to msat", extra={"amount_msat": amount} ) logger.info( "credit_balance: Updating balance", extra={"old_balance": key.balance, "credit_amount": amount}, ) # Use atomic SQL UPDATE to prevent race conditions during concurrent topups stmt = ( update(db.ApiKey) .where(col(db.ApiKey.hashed_key) == key.hashed_key) .values(balance=(db.ApiKey.balance) + amount) ) await session.exec(stmt) # type: ignore[call-overload] await session.commit() await session.refresh(key) logger.info( "credit_balance: Balance updated successfully", extra={"new_balance": key.balance}, ) logger.info( "Cashu token successfully redeemed and stored", extra={"amount": amount, "unit": unit, "mint_url": mint_url}, ) return amount except Exception as e: logger.error( "credit_balance: Error during token redemption", extra={"error": str(e), "error_type": type(e).__name__}, ) raise _wallets: dict[str, Wallet] = {} async def get_wallet(mint_url: str, unit: str = "sat", load: bool = True) -> Wallet: global _wallets id = f"{mint_url}_{unit}" if id not in _wallets: _wallets[id] = await Wallet.with_db(mint_url, db=".wallet", unit=unit) if load: await _wallets[id].load_mint() await _wallets[id].load_proofs(reload=True) return _wallets[id] def get_proofs_per_mint_and_unit( wallet: Wallet, mint_url: str, unit: str, not_reserved: bool = False ) -> list[Proof]: valid_keyset_ids = [ k.id for k in wallet.keysets.values() if k.mint_url == mint_url and k.unit.name == unit ] proofs = [p for p in wallet.proofs if p.id in valid_keyset_ids] if not_reserved: proofs = [p for p in proofs if not p.reserved] return proofs async def slow_filter_spend_proofs(proofs: list[Proof], wallet: Wallet) -> list[Proof]: if not proofs: return [] _proofs = [] _spent_proofs = [] for i in range(0, len(proofs), 1000): pb = proofs[i : i + 1000] proof_states = await wallet.check_proof_state(pb) for proof, state in zip(pb, proof_states.states): if str(state.state) != "spent": _proofs.append(proof) else: _spent_proofs.append(proof) await wallet.set_reserved_for_send(_spent_proofs, reserved=True) return _proofs class BalanceDetail(TypedDict, total=False): mint_url: str unit: str wallet_balance: int user_balance: int owner_balance: int error: str async def fetch_all_balances( units: list[str] | None = None, ) -> tuple[list[BalanceDetail], int, int, int]: """ Fetch balances for all trusted mints and units concurrently. Returns: - List of balance details for each mint/unit combination - Total wallet balance in sats - Total user balance in sats - Owner balance in sats (wallet - user) """ if units is None: units = ["sat", "msat"] async def fetch_balance( session: db.AsyncSession, mint_url: str, unit: str ) -> BalanceDetail: try: wallet = await get_wallet(mint_url, unit) proofs = get_proofs_per_mint_and_unit( wallet, mint_url, unit, not_reserved=True ) proofs = await slow_filter_spend_proofs(proofs, wallet) user_balance = await db.balances_for_mint_and_unit(session, mint_url, unit) if unit == "sat": user_balance = user_balance // 1000 proofs_balance = sum(proof.amount for proof in proofs) result: BalanceDetail = { "mint_url": mint_url, "unit": unit, "wallet_balance": proofs_balance, "user_balance": user_balance, "owner_balance": proofs_balance - user_balance, } return result except Exception as e: logger.error(f"Error getting balance for {mint_url} {unit}: {e}") error_result: BalanceDetail = { "mint_url": mint_url, "unit": unit, "wallet_balance": 0, "user_balance": 0, "owner_balance": 0, "error": str(e), } return error_result # Create tasks for all mint/unit combinations async with db.create_session() as session: tasks = [ fetch_balance(session, mint_url, unit) for mint_url in settings.cashu_mints for unit in units ] # Run all tasks concurrently balance_details = list(await asyncio.gather(*tasks)) # Calculate totals total_wallet_balance_sats = 0 total_user_balance_sats = 0 for detail in balance_details: if not detail.get("error"): # Convert to sats for total calculation unit = detail["unit"] proofs_balance_sats = ( detail["wallet_balance"] if unit == "sat" else detail["wallet_balance"] // 1000 ) user_balance_sats = ( detail["user_balance"] if unit == "sat" else detail["user_balance"] // 1000 ) total_wallet_balance_sats += proofs_balance_sats total_user_balance_sats += user_balance_sats owner_balance = total_wallet_balance_sats - total_user_balance_sats return ( balance_details, total_wallet_balance_sats, total_user_balance_sats, owner_balance, ) async def periodic_payout() -> None: if not settings.receive_ln_address: logger.error("RECEIVE_LN_ADDRESS is not set, skipping payout") return while True: await asyncio.sleep(60 * 15) try: async with db.create_session() as session: for mint_url in settings.cashu_mints: for unit in ["sat", "msat"]: wallet = await get_wallet(mint_url, unit) proofs = get_proofs_per_mint_and_unit( wallet, mint_url, unit, not_reserved=True ) proofs = await slow_filter_spend_proofs(proofs, wallet) user_balance = await db.balances_for_mint_and_unit( session, mint_url, unit ) if unit == "sat": user_balance = user_balance // 1000 proofs_balance = sum(proof.amount for proof in proofs) available_balance = proofs_balance - user_balance min_amount = 210 if unit == "sat" else 210000 if available_balance > min_amount: amount_received = await raw_send_to_lnurl( wallet, proofs, settings.receive_ln_address, unit, amount=available_balance, ) logger.info( "Payout sent successfully", extra={ "mint_url": mint_url, "unit": unit, "balance": available_balance, "amount_received": amount_received, }, ) await asyncio.sleep(5) except Exception as e: logger.error( f"Error sending payout: {type(e).__name__}", extra={"error": str(e)}, ) async def send_to_lnurl(amount: int, unit: str, mint: str, address: str) -> int: wallet = await get_wallet(mint, unit) proofs = wallet._get_proofs_per_keyset(wallet.proofs)[wallet.keyset_id] proofs, _ = await wallet.select_to_send(proofs, amount, set_reserved=True) return await raw_send_to_lnurl(wallet, proofs, address, unit) # class Payment: # """ # Stores all cashu payment related data # """ # def __init__(self, token: str) -> None: # self.initial_token = token # amount, unit, mint_url = self.parse_token(token) # self.amount = amount # self.unit = unit # self.mint_url = mint_url # self.claimed_proofs = redeem_to_proofs(token) # def parse_token(self, token: str) -> tuple[int, CurrencyUnit, str]: # raise NotImplementedError # def refund_full(self) -> None: # raise NotImplementedError # def refund_partial(self, amount: int) -> None: # raise NotImplementedError