import hashlib import hmac import json import logging from typing import Optional from aiocryptopay import AioCryptoPay, Networks from aiocryptopay.models.update import Update from aiogram import Bot, F, Router, types from aiohttp import web from pydantic import Field, field_validator from pydantic_settings import SettingsConfigDict from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import sessionmaker from bot.middlewares.i18n import JsonI18n from bot.services.referral_service import ReferralService from bot.services.subscription_service import SubscriptionService from config.settings import Settings from db.dal import payment_dal from .base import ( PaymentProviderSpec, ProviderEnvConfig, ProviderManifestField, ServiceFactoryContext, WebAppPaymentContext, provider_env_file, provider_runtime_enabled, ) from .shared import ( PaymentSuccessRequest, describe_payment, finalize_successful_payment, make_translator, notify_callback_parse_error, notify_service_unavailable, parse_payment_callback, payment_failed, payment_link_response, payment_record_amounts, payment_unavailable, quote_hwid_callback_parts, render_payment_link, sale_mode_base, sale_mode_is_traffic, sale_mode_tariff_key, ) logger = logging.getLogger(__name__) _LOG = "cryptopay" class CryptoPayConfig(ProviderEnvConfig): model_config = SettingsConfigDict( env_file=provider_env_file(), env_file_encoding="utf-8", env_prefix="CRYPTOPAY_", extra="ignore", ) ENABLED: bool = Field(default=True) TOKEN: Optional[str] = None NETWORK: str = Field(default="mainnet") CURRENCY_TYPE: str = Field(default="fiat") ASSET: str = Field(default="RUB") @field_validator("TOKEN", mode="before") @classmethod def _strip_optional(cls, v): if isinstance(v, str) and not v.strip(): return None return v @property def webhook_path(self) -> str: return "/webhook/cryptopay" class CryptoPayPresentation(ProviderEnvConfig): model_config = SettingsConfigDict( env_file=provider_env_file(), env_file_encoding="utf-8", env_prefix="PAYMENT_CRYPTOPAY_", extra="ignore", ) WEBAPP_LABEL_RU: Optional[str] = None WEBAPP_LABEL_EN: Optional[str] = None WEBAPP_ICON: Optional[str] = None TELEGRAM_LABEL_RU: Optional[str] = None TELEGRAM_LABEL_EN: Optional[str] = None TELEGRAM_EMOJI: Optional[str] = None class CryptoPayService: def __init__( self, bot: Bot, settings: Settings, config: CryptoPayConfig, i18n: JsonI18n, async_session_factory: sessionmaker, subscription_service: SubscriptionService, referral_service: ReferralService, ): self.bot = bot self.settings = settings self.config = config self.i18n = i18n self.async_session_factory = async_session_factory self.subscription_service = subscription_service self.referral_service = referral_service self._client = None self._client_token = None self._client_network = None if not self.config.TOKEN: logging.warning("CryptoPay token not provided. CryptoPay disabled") @property def token(self): return self.config.TOKEN @property def configured(self) -> bool: return bool(provider_runtime_enabled(self.config) and self.config.TOKEN) @property def client(self): # Recreate the SDK client whenever the admin changes the token / network # at runtime — otherwise we'd keep talking to the old account. token = self.config.TOKEN network = self.config.NETWORK if not token: return None if self._client is None or token != self._client_token or network != self._client_network: net = Networks.TEST_NET if str(network).lower() == "testnet" else Networks.MAIN_NET self._client = AioCryptoPay(token=token, network=net) self._client.register_pay_handler(self._invoice_paid_handler) self._client_token = token self._client_network = network return self._client async def close(self): if self._client: try: await self._client.close() logging.info("CryptoPay client session closed.") except Exception as e: logging.warning("Failed to close CryptoPay client: %s", e) async def create_invoice( self, session: AsyncSession, user_id: int, months: int, amount: float, description: str, sale_mode: str = "subscription", url_kind: str = "bot", hwid_quote: Optional[dict] = None, ) -> Optional[str]: if not self.configured or not self.client: logging.error("CryptoPayService not configured") return None sale_base = sale_mode_base(sale_mode) amounts = payment_record_amounts(months=months, sale_mode=sale_mode) try: payment_record = await payment_dal.create_payment_record( session, { "user_id": user_id, "amount": float(amount), "currency": self.config.ASSET, "status": "pending_cryptopay", "description": description, "subscription_duration_months": ( int(months) if sale_base == "subscription" else None ), "provider": "cryptopay", "sale_mode": sale_mode, "tariff_key": sale_mode_tariff_key(sale_mode), "purchased_gb": amounts.purchased_gb, "purchased_hwid_devices": amounts.purchased_hwid_devices, "hwid_valid_from": hwid_quote.get("valid_from") if hwid_quote else None, "hwid_valid_until": hwid_quote.get("valid_until") if hwid_quote else None, "hwid_pricing_period_months": hwid_quote.get("pricing_period_months") if hwid_quote else None, "hwid_proration_ratio": hwid_quote.get("proration_ratio") if hwid_quote else None, "hwid_full_price": hwid_quote.get("full_price") if hwid_quote else None, }, ) await session.commit() except Exception: await session.rollback() logging.exception("Failed to create cryptopay payment record for user %s.", user_id) return None payload = json.dumps( { "user_id": str(user_id), "subscription_months": str(months), "payment_db_id": str(payment_record.payment_id), "sale_mode": sale_mode, "traffic_gb": str(months) if sale_mode_is_traffic(sale_mode) else None, } ) try: invoice = await self.client.create_invoice( amount=amount, currency_type=self.config.CURRENCY_TYPE, fiat=self.config.ASSET if self.config.CURRENCY_TYPE == "fiat" else None, asset=self.config.ASSET if self.config.CURRENCY_TYPE == "crypto" else None, description=description, payload=payload, ) try: await payment_dal.update_provider_payment_and_status( session, payment_record.payment_id, str(invoice.invoice_id), str(invoice.status), ) await session.commit() except Exception: await session.rollback() logging.exception( "Failed to update cryptopay payment record %s.", payment_record.payment_id, ) return None if url_kind == "web": return ( getattr(invoice, "web_app_invoice_url", None) or getattr(invoice, "mini_app_invoice_url", None) or invoice.bot_invoice_url ) return invoice.bot_invoice_url except Exception: logging.exception("CryptoPay invoice creation failed.") return None async def _invoice_paid_handler(self, update: Update, app: web.Application): invoice = update.payload if not invoice.payload: logging.warning("CryptoPay webhook without payload") return try: meta = json.loads(invoice.payload) user_id = int(meta["user_id"]) months = float(meta.get("subscription_months") or 0) payment_db_id = int(meta["payment_db_id"]) sale_mode = meta.get("sale_mode") or ( "traffic" if self.settings.traffic_sale_mode else "subscription" ) traffic_gb = float(meta.get("traffic_gb")) if meta.get("traffic_gb") else months except Exception: logging.exception("Failed to parse CryptoPay payload.") return async_session_factory: sessionmaker = app["async_session_factory"] bot: Bot = app["bot"] settings: Settings = app["settings"] i18n: JsonI18n = app["i18n"] subscription_service: SubscriptionService = app["subscription_service"] referral_service: ReferralService = app["referral_service"] async with async_session_factory() as session: try: await payment_dal.update_provider_payment_and_status( session, payment_db_id, str(invoice.invoice_id), "succeeded", ) await session.commit() except Exception: await session.rollback() logging.exception( "Failed to mark CryptoPay invoice %s as succeeded.", payment_db_id, ) return payment = await payment_dal.get_payment_by_db_id(session, payment_db_id) if not payment: logging.error( "CryptoPay webhook: payment %s vanished after status update.", payment_db_id, ) return currency = invoice.asset or settings.DEFAULT_CURRENCY_SYMBOL await finalize_successful_payment( PaymentSuccessRequest( bot=bot, settings=settings, i18n=i18n, session=session, subscription_service=subscription_service, referral_service=referral_service, payment=payment, user_id=user_id, amount=float(invoice.amount), currency=str(currency), sale_mode=sale_mode, months=int(months) if months else int(traffic_gb), traffic_amount=float(traffic_gb), provider_subscription="cryptopay", provider_notification="crypto_pay", log_prefix="CryptoPay webhook", ) ) def _validate_webhook_signature(self, raw_body: bytes, signature: str) -> bool: if not self.token: return False expected_signature = hmac.new( hashlib.sha256(self.token.encode("utf-8")).digest(), raw_body, hashlib.sha256, ).hexdigest() if not hmac.compare_digest(expected_signature, signature or ""): logger.error("CryptoPay signature mismatch") return False return True async def webhook_route(self, request: web.Request) -> web.Response: if not self.configured or not self.client: return web.Response(status=503, text="cryptopay_disabled") raw_body = await request.read() signature = request.headers.get("crypto-pay-api-signature", "") if not self._validate_webhook_signature(raw_body, signature): return web.Response(status=401) return await self.client.get_updates(request) async def cryptopay_webhook_route(request: web.Request) -> web.Response: service: CryptoPayService = request.app["cryptopay_service"] return await service.webhook_route(request) router = Router(name="user_subscription_payments_crypto_router") @router.callback_query(F.data.startswith("pay_crypto:")) async def pay_crypto_callback_handler( callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession, cryptopay_service: CryptoPayService, ): current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") translator = make_translator(i18n, current_lang) if not i18n or not callback.message: await notify_callback_parse_error(callback, translator) return if not SPEC.is_available_to_user( settings, user_id=callback.from_user.id, require_configured=False, ): await notify_service_unavailable(callback, translator) return if not cryptopay_service or not getattr(cryptopay_service, "configured", False): await notify_service_unavailable(callback, translator) return parts = parse_payment_callback(callback.data or "") if not parts: await notify_callback_parse_error(callback, translator) return parts, hwid_quote = await quote_hwid_callback_parts( session=session, user_id=callback.from_user.id, parts=parts, subscription_service=cryptopay_service.subscription_service, currency="rub", ) if not parts: await notify_callback_parse_error(callback, translator) return payment_description = describe_payment(translator, parts) invoice_url = await cryptopay_service.create_invoice( session=session, user_id=callback.from_user.id, months=parts.months, amount=parts.price, description=payment_description, sale_mode=parts.sale_mode, hwid_quote=hwid_quote, ) if invoice_url: await render_payment_link( callback, translator=translator, current_lang=current_lang, i18n=i18n, parts=parts, payment_url=invoice_url, log_prefix=_LOG, ) return from .shared import safe_callback_answer await safe_callback_answer(callback, translator("error_payment_gateway"), show_alert=True) def create_service(ctx: ServiceFactoryContext) -> CryptoPayService: bundle = ctx.config_for("cryptopay_service") config = ( bundle.config if bundle and isinstance(bundle.config, CryptoPayConfig) else CryptoPayConfig() ) return CryptoPayService( bot=ctx.bot, settings=ctx.settings, config=config, i18n=ctx.i18n, async_session_factory=ctx.async_session_factory, subscription_service=ctx.subscription_service, referral_service=ctx.referral_service, ) async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: service: CryptoPayService = ctx.request.app["cryptopay_service"] if not service or not service.configured: return payment_unavailable() url = await service.create_invoice( session=ctx.session, user_id=ctx.user_id, months=ctx.months, amount=ctx.price, description=ctx.description, sale_mode=ctx.sale_mode, url_kind="web", hwid_quote={ "valid_from": ctx.hwid_valid_from, "valid_until": ctx.hwid_valid_until, "pricing_period_months": ctx.hwid_pricing_period_months, "proration_ratio": ctx.hwid_proration_ratio, "full_price": ctx.hwid_full_price, } if ctx.hwid_valid_from and ctx.hwid_valid_until else None, ) if not url: return payment_failed() return payment_link_response(payment_url=url, payment_id=None) _PRESENTATION_MANIFEST = tuple( ProviderManifestField( key=key, type=type_, label=label, description=description, placeholder=placeholder, subsection="CryptoPay", target="presentation", attr=attr, ) for key, type_, label, description, placeholder, attr in ( ( "PAYMENT_CRYPTOPAY_WEBAPP_LABEL_RU", "string", "WebApp button text (RU)", "Custom Russian text shown in the Web App payment method button.", "", "WEBAPP_LABEL_RU", ), ( "PAYMENT_CRYPTOPAY_WEBAPP_LABEL_EN", "string", "WebApp button text (EN)", "Custom English text shown in the Web App payment method button.", "", "WEBAPP_LABEL_EN", ), ( "PAYMENT_CRYPTOPAY_WEBAPP_ICON", "icon", "WebApp button icon", "Lucide icon name rendered inside the Web App payment method button.", "Bitcoin", "WEBAPP_ICON", ), ( "PAYMENT_CRYPTOPAY_TELEGRAM_LABEL_RU", "string", "Telegram button text (RU)", "Custom Russian text shown in Telegram bot payment buttons.", "", "TELEGRAM_LABEL_RU", ), ( "PAYMENT_CRYPTOPAY_TELEGRAM_LABEL_EN", "string", "Telegram button text (EN)", "Custom English text shown in Telegram bot payment buttons.", "", "TELEGRAM_LABEL_EN", ), ( "PAYMENT_CRYPTOPAY_TELEGRAM_EMOJI", "string", "Telegram button emoji", "Emoji prepended to the Telegram bot payment button when customized.", "₿", "TELEGRAM_EMOJI", ), ) ) _CONFIG_MANIFEST = ( ProviderManifestField( "CRYPTOPAY_ENABLED", "bool", "Включена", subsection="CryptoPay", attr="ENABLED" ), ProviderManifestField( "CRYPTOPAY_TOKEN", "string", "Token", subsection="CryptoPay", secret=True, attr="TOKEN" ), ProviderManifestField( "CRYPTOPAY_NETWORK", "string", "Network", placeholder="mainnet", subsection="CryptoPay", attr="NETWORK", ), ProviderManifestField( "CRYPTOPAY_CURRENCY_TYPE", "string", "Currency type", description="fiat or crypto.", placeholder="fiat", subsection="CryptoPay", attr="CURRENCY_TYPE", ), ProviderManifestField( "CRYPTOPAY_ASSET", "string", "Asset", placeholder="RUB", subsection="CryptoPay", attr="ASSET", ), ) SPEC = PaymentProviderSpec( id="cryptopay", provider_key="cryptopay", label="CryptoPay", webapp_label="CryptoPay", webapp_labels={"ru": "CryptoPay", "en": "CryptoPay"}, webapp_icon="Bitcoin", telegram_labels={"ru": "CryptoBot", "en": "CryptoBot"}, pending_status="pending_cryptopay", enabled=lambda config: bool(getattr(config, "ENABLED", False)), service_key="cryptopay_service", callback_prefix="pay_crypto", router=router, create_service=create_service, webhook_path=lambda source: "/webhook/cryptopay", webhook_route=cryptopay_webhook_route, create_webapp_payment=create_webapp_payment, emoji="₿", telegram_emoji="₿", config_class=CryptoPayConfig, presentation_class=CryptoPayPresentation, manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST, )