diff --git a/README.md b/README.md index 3b4c4a6..06c13d1 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,20 @@ Requires Python 3.10+. --- +## Logging + +`pyfragment` uses standard Python logging under the `pyfragment` namespace and is silent by default. +To enable logs, configure your app's logging and set the level: + +```python +import logging + +logging.basicConfig(level=logging.INFO) +logging.getLogger("pyfragment").setLevel(logging.DEBUG) # DEBUG for detailed request logs +``` + +--- + ## Credentials **Fragment cookies** — log in to [fragment.com](https://fragment.com) and connect your TON wallet. You can get cookies in two ways: diff --git a/pyfragment/__init__.py b/pyfragment/__init__.py index ac06351..2b33d5e 100644 --- a/pyfragment/__init__.py +++ b/pyfragment/__init__.py @@ -1,3 +1,4 @@ +import logging from importlib.metadata import version from pyfragment.client import FragmentClient @@ -26,6 +27,8 @@ from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesR from pyfragment.models.payments import AdsRechargeResult, AdsTopupResult, PremiumResult, StarsResult from pyfragment.models.wallet import WalletInfo +logging.getLogger("pyfragment").addHandler(logging.NullHandler()) + __version__: str = version("pyfragment") __all__ = [ diff --git a/pyfragment/client.py b/pyfragment/client.py index f452581..8bfb4c1 100644 --- a/pyfragment/client.py +++ b/pyfragment/client.py @@ -3,12 +3,10 @@ from __future__ import annotations import json from typing import Any, cast, get_args -import httpx - -from pyfragment.core.constants import BASE_HEADERS, DEFAULT_TIMEOUT, FRAGMENT_BASE_URL, REQUIRED_COOKIE_KEYS -from pyfragment.core.transport import fragment_request, get_fragment_hash +from pyfragment.core.constants import DEFAULT_TIMEOUT, FRAGMENT_BASE_URL, REQUIRED_COOKIE_KEYS from pyfragment.domains.ads.service import AdsService from pyfragment.domains.anonymous_numbers.service import AnonymousNumbersService +from pyfragment.domains.base import raw_api_call from pyfragment.domains.giveaways.service import GiveawaysService from pyfragment.domains.marketplace.service import MarketplaceService from pyfragment.domains.purchases.service import PurchasesService @@ -31,7 +29,7 @@ class FragmentClient: connected with Fragment or Telegram. Args: - seed: 24-word mnemonic phrase for the TON wallet. + seed: 12-, 18-, or 24-word mnemonic phrase for the TON wallet. api_key: Tonapi API key — get one at https://tonconsole.com. cookies: Fragment session cookies as a dict or JSON string. wallet_version: Wallet contract version — ``"V4R2"`` or ``"V5R1"`` (default). @@ -53,14 +51,17 @@ class FragmentClient: print(result.transaction_id) """ - def __init__( - self, - seed: str, - api_key: str, - cookies: dict[str, Any] | str, - wallet_version: str = "V5R1", - timeout: float = DEFAULT_TIMEOUT, - ) -> None: + @staticmethod + def _parse_cookies(cookies: dict[str, Any] | str) -> dict[str, Any]: + if isinstance(cookies, str): + try: + cookies = json.loads(cookies) + except Exception as exc: + raise CookieError(CookieError.READ_FAILED.format(exc=exc)) from exc + return cast(dict[str, Any], cookies) + + @staticmethod + def _validate_required(seed: str, api_key: str) -> None: missing = [name for name, val in (("seed", seed), ("api_key", api_key)) if not val or not str(val).strip()] if missing: raise ConfigurationError(ConfigurationError.MISSING_VARS.format(keys=", ".join(missing))) @@ -72,16 +73,14 @@ class FragmentClient: if len(api_key.strip()) < 68: raise ConfigurationError(ConfigurationError.INVALID_API_KEY.format(length=len(api_key.strip()))) - if isinstance(cookies, str): - try: - cookies = json.loads(cookies) - except Exception as exc: - raise CookieError(CookieError.READ_FAILED.format(exc=exc)) from exc - - missing_keys = [k for k in REQUIRED_COOKIE_KEYS if not str(cast(dict[str, Any], cookies).get(k, "")).strip()] + @staticmethod + def _validate_cookie_keys(cookies: dict[str, Any]) -> None: + missing_keys = [k for k in REQUIRED_COOKIE_KEYS if not str(cookies.get(k, "")).strip()] if missing_keys: raise CookieError(CookieError.MISSING_KEYS.format(keys=", ".join(missing_keys))) + @staticmethod + def _normalize_wallet_version(wallet_version: str) -> WalletVersion: version = wallet_version.strip().upper() if version not in get_args(WalletVersion): raise ConfigurationError( @@ -89,11 +88,25 @@ class FragmentClient: version=version, supported=", ".join(sorted(get_args(WalletVersion))) ) ) + return cast(WalletVersion, version) + + def __init__( + self, + seed: str, + api_key: str, + cookies: dict[str, Any] | str, + wallet_version: str = "V5R1", + timeout: float = DEFAULT_TIMEOUT, + ) -> None: + self._validate_required(seed, api_key) + parsed_cookies = self._parse_cookies(cookies) + self._validate_cookie_keys(parsed_cookies) + version = self._normalize_wallet_version(wallet_version) self.seed: str = seed.strip() self.api_key: str = api_key.strip() - self.cookies: dict[str, Any] = cast(dict[str, Any], cookies) - self.wallet_version: WalletVersion = version # type: ignore[assignment] + self.cookies: dict[str, Any] = parsed_cookies + self.wallet_version: WalletVersion = version self.timeout: float = timeout self.marketplace = MarketplaceService(self) self.purchases = PurchasesService(self) @@ -371,7 +384,4 @@ class FragmentClient: page_url="https://fragment.com/premium/gift", ) """ - headers = {**BASE_HEADERS, "referer": page_url, "x-aj-referer": page_url} - async with httpx.AsyncClient(cookies=self.cookies, timeout=self.timeout) as session: - fragment_hash = await get_fragment_hash(self.cookies, headers, page_url, self.timeout) - return await fragment_request(session, fragment_hash, headers, {"method": method, **(data or {})}) + return await raw_api_call(self.cookies, self.timeout, method, data, page_url) diff --git a/pyfragment/domains/ads/recharge.py b/pyfragment/domains/ads/recharge.py index 5c6285e..4b0163e 100644 --- a/pyfragment/domains/ads/recharge.py +++ b/pyfragment/domains/ads/recharge.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import logging from typing import TYPE_CHECKING from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE @@ -13,6 +14,9 @@ if TYPE_CHECKING: from pyfragment.client import FragmentClient +logger = logging.getLogger(__name__) + + async def recharge_ads(client: FragmentClient, account: str, amount: int) -> AdsRechargeResult: if not isinstance(amount, int) or not (1 <= amount <= 1_000_000_000): raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT) @@ -42,7 +46,9 @@ async def recharge_ads(client: FragmentClient, account: str, amount: int) -> Ads tx_hash = await process_transaction(client, transaction) return AdsRechargeResult(transaction_id=tx_hash, amount=amount) - except FragmentError: + except FragmentError as exc: + logger.error("Failed to recharge Ads account '%s' for %s TON: %s", account, amount, exc, exc_info=True) raise except Exception as exc: + logger.exception("Failed to recharge Ads account '%s' for %s TON due to an unexpected error", account, amount) raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc diff --git a/pyfragment/domains/ads/tonup.py b/pyfragment/domains/ads/tonup.py index c86a77c..0b30c26 100644 --- a/pyfragment/domains/ads/tonup.py +++ b/pyfragment/domains/ads/tonup.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import logging from typing import TYPE_CHECKING from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE @@ -21,6 +22,9 @@ if TYPE_CHECKING: from pyfragment.client import FragmentClient +logger = logging.getLogger(__name__) + + async def topup_ton(client: FragmentClient, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult: if not isinstance(amount, int) or not (1 <= amount <= 1_000_000_000): raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT) @@ -57,7 +61,9 @@ async def topup_ton(client: FragmentClient, username: str, amount: int, show_sen tx_hash = await process_transaction(client, transaction, required_payment_amount=required_payment_amount) return AdsTopupResult(transaction_id=tx_hash, username=username, amount=amount) - except FragmentError: + except FragmentError as exc: + logger.error("Failed to top up TON for user '%s' with %s TON: %s", username, amount, exc, exc_info=True) raise except Exception as exc: + logger.exception("Failed to top up TON for user '%s' with %s TON due to an unexpected error", username, amount) raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc diff --git a/pyfragment/domains/anonymous_numbers/number.py b/pyfragment/domains/anonymous_numbers/number.py index 9a84c48..8a432be 100644 --- a/pyfragment/domains/anonymous_numbers/number.py +++ b/pyfragment/domains/anonymous_numbers/number.py @@ -1,6 +1,7 @@ from __future__ import annotations import html +import logging from typing import TYPE_CHECKING from pyfragment.core.constants import NUMBERS_PAGE @@ -12,6 +13,9 @@ if TYPE_CHECKING: from pyfragment.client import FragmentClient +logger = logging.getLogger(__name__) + + def _strip_plus(number: str) -> str: return number.lstrip("+") if isinstance(number, str) else number @@ -32,9 +36,11 @@ async def get_login_code(client: FragmentClient, number: str) -> LoginCodeResult return LoginCodeResult(number=number, code=code, active_sessions=active_sessions) - except FragmentError: + except FragmentError as exc: + logger.error("Failed to get login code for number '%s': %s", number, exc, exc_info=True) raise except Exception as exc: + logger.exception("Failed to get login code for number '%s' due to an unexpected error", number) raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc @@ -50,9 +56,21 @@ async def toggle_login_codes(client: FragmentClient, number: str, can_receive: b if result.get("error"): raise FragmentAPIError(html.unescape(result["error"])) - except FragmentError: + except FragmentError as exc: + logger.error( + "Failed to toggle login code delivery for number '%s' (can_receive=%s): %s", + number, + can_receive, + exc, + exc_info=True, + ) raise except Exception as exc: + logger.exception( + "Failed to toggle login code delivery for number '%s' (can_receive=%s) due to an unexpected error", + number, + can_receive, + ) raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc @@ -88,7 +106,9 @@ async def terminate_sessions(client: FragmentClient, number: str) -> TerminateSe return TerminateSessionsResult(number=number, message=result.get("msg")) - except FragmentError: + except FragmentError as exc: + logger.error("Failed to terminate sessions for number '%s': %s", number, exc, exc_info=True) raise except Exception as exc: + logger.exception("Failed to terminate sessions for number '%s' due to an unexpected error", number) raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc diff --git a/pyfragment/domains/base.py b/pyfragment/domains/base.py index 01d5ca5..6dce538 100644 --- a/pyfragment/domains/base.py +++ b/pyfragment/domains/base.py @@ -1,10 +1,39 @@ from __future__ import annotations -from typing import TYPE_CHECKING +import logging +from typing import TYPE_CHECKING, Any + +import httpx + +from pyfragment.core.constants import BASE_HEADERS +from pyfragment.core.transport import fragment_request, get_fragment_hash if TYPE_CHECKING: from pyfragment.client import FragmentClient +logger = logging.getLogger(__name__) + + +async def raw_api_call( + cookies: dict[str, Any], + timeout: float, + method: str, + data: dict[str, Any] | None, + page_url: str, +) -> dict[str, Any]: + payload = {"method": method, **(data or {})} + headers = {**BASE_HEADERS, "referer": page_url, "x-aj-referer": page_url} + logger.debug("Starting Fragment API call '%s' on %s", method, page_url) + try: + async with httpx.AsyncClient(cookies=cookies, timeout=timeout) as session: + fragment_hash = await get_fragment_hash(cookies, headers, page_url, timeout) + response = await fragment_request(session, fragment_hash, headers, payload) + logger.debug("Completed Fragment API call '%s' with response keys: %s", method, sorted(response.keys())) + return response + except Exception: + logger.exception("Failed to call Fragment API method '%s' on %s", method, page_url) + raise + class BaseService: def __init__(self, client: FragmentClient) -> None: diff --git a/pyfragment/domains/giveaways/giveaway.py b/pyfragment/domains/giveaways/giveaway.py index ab1af30..e8ab668 100644 --- a/pyfragment/domains/giveaways/giveaway.py +++ b/pyfragment/domains/giveaways/giveaway.py @@ -1,6 +1,8 @@ from __future__ import annotations import json +import logging +import random from typing import TYPE_CHECKING, get_args from pyfragment.core.constants import DEVICE, PREMIUM_GIVEAWAY_PAGE, STARS_GIVEAWAY_PAGE @@ -22,6 +24,9 @@ if TYPE_CHECKING: from pyfragment.client import FragmentClient +logger = logging.getLogger(__name__) + + async def giveaway_stars( client: FragmentClient, channel: str, @@ -47,6 +52,12 @@ async def giveaway_stars( if not recipient: raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=channel)) + await client.call( + "updateStarsGiveawayState", + {"mode": "new", "lv": "false", "dh": str(random.randint(100_000_000, 999_999_999))}, + page_url=STARS_GIVEAWAY_PAGE, + ) + result = await client.call( "initGiveawayStarsRequest", { @@ -84,9 +95,25 @@ async def giveaway_stars( ) return StarsGiveawayResult(transaction_id=tx_hash, channel=channel, winners=winners, amount=amount) - except FragmentError: + except FragmentError as exc: + logger.error( + "Failed to run Stars giveaway for channel '%s' (winners=%s, amount=%s, payment_method='%s'): %s", + channel, + winners, + amount, + payment_method, + exc, + exc_info=True, + ) raise except Exception as exc: + logger.exception( + "Failed to run Stars giveaway for channel '%s' (winners=%s, amount=%s, payment_method='%s') due to an unexpected error", + channel, + winners, + amount, + payment_method, + ) raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc @@ -119,6 +146,17 @@ async def giveaway_premium( if not recipient: raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=channel)) + await client.call( + "updatePremiumGiveawayState", + { + "mode": "new", + "lv": "false", + "dh": str(random.randint(100_000_000, 999_999_999)), + "quantity": "", + }, + page_url=PREMIUM_GIVEAWAY_PAGE, + ) + result = await client.call( "initGiveawayPremiumRequest", { @@ -156,7 +194,23 @@ async def giveaway_premium( ) return PremiumGiveawayResult(transaction_id=tx_hash, channel=channel, winners=winners, amount=months) - except FragmentError: + except FragmentError as exc: + logger.error( + "Failed to run Premium giveaway for channel '%s' (winners=%s, months=%s, payment_method='%s'): %s", + channel, + winners, + months, + payment_method, + exc, + exc_info=True, + ) raise except Exception as exc: + logger.exception( + "Failed to run Premium giveaway for channel '%s' (winners=%s, months=%s, payment_method='%s') due to an unexpected error", + channel, + winners, + months, + payment_method, + ) raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc diff --git a/pyfragment/domains/marketplace/search.py b/pyfragment/domains/marketplace/search.py index a7f6807..2f5b6d5 100644 --- a/pyfragment/domains/marketplace/search.py +++ b/pyfragment/domains/marketplace/search.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from typing import TYPE_CHECKING, Any from pyfragment.core.constants import FRAGMENT_BASE_URL, GIFTS_PAGE, NUMBERS_PAGE @@ -11,6 +12,9 @@ if TYPE_CHECKING: from pyfragment.client import FragmentClient +logger = logging.getLogger(__name__) + + async def search_usernames( client: FragmentClient, query: str = "", @@ -36,9 +40,19 @@ async def search_usernames( next_offset_id = str(raw_noi) if raw_noi else None return UsernamesResult(items=items, next_offset_id=next_offset_id) - except FragmentError: + except FragmentError as exc: + logger.error( + "Failed to search usernames (query='%s', sort='%s', filter='%s', offset_id='%s'): %s", + query, + sort, + filter, + offset_id, + exc, + exc_info=True, + ) raise except Exception as exc: + logger.exception("Failed to search usernames for query '%s' due to an unexpected error", query) raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc @@ -67,9 +81,19 @@ async def search_numbers( next_offset_id = str(raw_noi) if raw_noi else None return NumbersResult(items=items, next_offset_id=next_offset_id) - except FragmentError: + except FragmentError as exc: + logger.error( + "Failed to search numbers (query='%s', sort='%s', filter='%s', offset_id='%s'): %s", + query, + sort, + filter, + offset_id, + exc, + exc_info=True, + ) raise except Exception as exc: + logger.exception("Failed to search numbers for query '%s' due to an unexpected error", query) raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc @@ -106,7 +130,19 @@ async def search_gifts( items, next_offset = parse_gift_items(result.get("html") or "") return GiftsResult(items=items, next_offset=next_offset) - except FragmentError: + except FragmentError as exc: + logger.error( + "Failed to search gifts (query='%s', collection='%s', sort='%s', filter='%s', view='%s', offset='%s'): %s", + query, + collection, + sort, + filter, + view, + offset, + exc, + exc_info=True, + ) raise except Exception as exc: + logger.exception("Failed to search gifts for query '%s' due to an unexpected error", query) raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc diff --git a/pyfragment/domains/purchases/purchase.py b/pyfragment/domains/purchases/purchase.py index 52d24e4..5d60c5d 100644 --- a/pyfragment/domains/purchases/purchase.py +++ b/pyfragment/domains/purchases/purchase.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import logging import time from typing import TYPE_CHECKING, get_args @@ -23,6 +24,9 @@ if TYPE_CHECKING: from pyfragment.client import FragmentClient +logger = logging.getLogger(__name__) + + async def purchase_stars( client: FragmentClient, username: str, @@ -84,9 +88,23 @@ async def purchase_stars( ) return StarsResult(transaction_id=tx_hash, username=username, amount=amount) - except FragmentError: + except FragmentError as exc: + logger.error( + "Failed to purchase %s Stars for user '%s' using '%s': %s", + amount, + username, + payment_method, + exc, + exc_info=True, + ) raise except Exception as exc: + logger.exception( + "Failed to purchase %s Stars for user '%s' using '%s' due to an unexpected error", + amount, + username, + payment_method, + ) raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc @@ -151,7 +169,21 @@ async def purchase_premium( ) return PremiumResult(transaction_id=tx_hash, username=username, amount=months) - except FragmentError: + except FragmentError as exc: + logger.error( + "Failed to purchase %s months of Premium for user '%s' using '%s': %s", + months, + username, + payment_method, + exc, + exc_info=True, + ) raise except Exception as exc: + logger.exception( + "Failed to purchase %s months of Premium for user '%s' using '%s' due to an unexpected error", + months, + username, + payment_method, + ) raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc diff --git a/pyfragment/domains/tonapi/account.py b/pyfragment/domains/tonapi/account.py index cd150fd..c532d35 100644 --- a/pyfragment/domains/tonapi/account.py +++ b/pyfragment/domains/tonapi/account.py @@ -1,6 +1,7 @@ from __future__ import annotations import base64 +import logging from typing import TYPE_CHECKING, Any from ton_core import NetworkGlobalID @@ -16,6 +17,9 @@ if TYPE_CHECKING: from pyfragment.client import FragmentClient +logger = logging.getLogger(__name__) + + async def get_usdt_balance(ton: Any, wallet_address: str) -> float: """Return the USDT balance for a Fragment-linked TON wallet.""" try: @@ -29,9 +33,12 @@ async def get_usdt_balance(ton: Any, wallet_address: str) -> float: return float(raw_balance) / 1_000_000.0 except ProviderResponseError as exc: if exc.code == 404: + logger.debug("No USDT jetton wallet found for '%s'; treating balance as 0", wallet_address) return 0.0 + logger.error("Failed to load USDT balance for wallet '%s': %s", wallet_address, exc, exc_info=True) raise WalletError(WalletError.USDT_BALANCE_CHECK_FAILED.format(exc=exc)) from exc except Exception as exc: + logger.exception("Failed to load USDT balance for wallet '%s' due to an unexpected error", wallet_address) raise WalletError(WalletError.USDT_BALANCE_CHECK_FAILED.format(exc=exc)) from exc @@ -47,6 +54,11 @@ async def check_ton_payment_balance( required_ton = max(tx_price_ton, MIN_TON_BALANCE) if balance_ton < required_ton: + logger.error( + "Failed TON balance check: balance=%s TON, required=%s TON", + round(balance_ton, 6), + round(required_ton, 6), + ) raise WalletError(WalletError.LOW_TON_BALANCE.format(balance=balance_ton, required=required_ton)) @@ -58,11 +70,22 @@ async def check_usdt_payment_balance( ) -> None: """Validate that the wallet can cover a USDT-denominated payment.""" if balance_ton < MIN_TON_BALANCE: + logger.error( + "Failed TON gas reserve check for USDT payment: balance=%s TON, required=%s TON", + round(balance_ton, 6), + MIN_TON_BALANCE, + ) raise WalletError(WalletError.LOW_TON_BALANCE.format(balance=balance_ton, required=MIN_TON_BALANCE)) usdt_balance = await get_usdt_balance(ton, wallet_address) required_usdt = required_payment_amount if required_payment_amount is not None else MIN_USDT_BALANCE if usdt_balance < required_usdt: + logger.error( + "Failed USDT balance check for wallet '%s': balance=%s USDT, required=%s USDT", + wallet_address, + round(usdt_balance, 6), + round(required_usdt, 6), + ) raise WalletError(WalletError.LOW_USDT_BALANCE.format(balance=usdt_balance, required=required_usdt)) @@ -80,6 +103,7 @@ async def get_account_info(client: FragmentClient) -> dict[str, Any]: "walletStateInit": base64.b64encode(boc).decode(), } except Exception as exc: + logger.exception("Failed to build Fragment account info from the configured wallet") raise WalletError(WalletError.ACCOUNT_INFO_FAILED.format(exc=exc)) from exc @@ -99,4 +123,5 @@ async def get_wallet_info(client: FragmentClient) -> WalletInfo: usdt_balance=round(usdt_balance, 4), ) except Exception as exc: + logger.exception("Failed to fetch wallet info from Tonapi") raise WalletError(WalletError.WALLET_INFO_FAILED.format(exc=exc)) from exc diff --git a/pyfragment/domains/tonapi/transaction.py b/pyfragment/domains/tonapi/transaction.py index 157ba7b..06f6bb0 100644 --- a/pyfragment/domains/tonapi/transaction.py +++ b/pyfragment/domains/tonapi/transaction.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio import base64 +import logging import random import ssl from typing import TYPE_CHECKING, Any @@ -19,6 +20,9 @@ if TYPE_CHECKING: from pyfragment.client import FragmentClient +logger = logging.getLogger(__name__) + + def clean_decode(payload: str) -> str | Cell: """Decode a base64 BOC comment from Fragment into text when possible. @@ -43,6 +47,7 @@ def clean_decode(payload: str) -> str | Cell: except UnicodeDecodeError: return cell except Exception as exc: + logger.exception("Failed to decode Fragment payload") raise ParseError(ParseError.UNPARSEABLE.format(context="payload decode", exc=exc)) from exc @@ -64,6 +69,7 @@ async def process_transaction( Normalized transaction hash string. """ if "transaction" not in transaction_data or not transaction_data["transaction"].get("messages"): + logger.error("Failed to process transaction: missing transaction payload or messages") raise TransactionError(TransactionError.INVALID_PAYLOAD) message = transaction_data["transaction"]["messages"][0] @@ -87,6 +93,7 @@ async def process_transaction( except WalletError: raise except Exception as exc: + logger.exception("Failed to validate balances before broadcasting transaction") raise WalletError(WalletError.TON_BALANCE_CHECK_FAILED.format(exc=exc)) from exc try: @@ -103,12 +110,24 @@ async def process_transaction( return str(result.normalized_hash) except ProviderResponseError as exc: if exc.code == 429 and attempt == 0: + logger.warning( + "Broadcast rate-limited (429), retrying transaction once: %s", + exc, + exc_info=True, + ) await asyncio.sleep(1 + random.uniform(0, 0.5)) continue if exc.code == 406 and "seqno" in str(exc).lower(): if attempt < 2: + logger.warning( + "Broadcast seqno conflict (406), retrying attempt %s: %s", + attempt + 2, + exc, + exc_info=True, + ) await asyncio.sleep(2 + random.uniform(0, 1)) continue + logger.error("Failed to broadcast transaction after seqno retries") raise TransactionError(TransactionError.DUPLICATE_SEQNO) from exc raise except (WalletError, TransactionError): @@ -117,8 +136,16 @@ async def process_transaction( cause: BaseException | None = exc while cause is not None: if isinstance(cause, ssl.SSLError): + logger.exception("Failed to broadcast transaction due to SSL error") raise TransactionError(TransactionError.BROADCAST_FAILED_SSL.format(exc=exc)) from exc cause = cause.__cause__ or cause.__context__ + logger.exception( + "Failed to broadcast transaction to '%s' for %s nanotons using payment method '%s'", + message["address"], + message["amount"], + payment_method, + ) raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc=exc)) from exc + logger.error("Failed to broadcast transaction: transfer loop exited without result") raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc="transfer loop exited without result")) diff --git a/pyfragment/domains/tonapi/transfer.py b/pyfragment/domains/tonapi/transfer.py index 0870a89..0f0854f 100644 --- a/pyfragment/domains/tonapi/transfer.py +++ b/pyfragment/domains/tonapi/transfer.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from typing import TYPE_CHECKING from ton_core import Address, NetworkGlobalID @@ -14,6 +15,9 @@ if TYPE_CHECKING: from pyfragment.client import FragmentClient +logger = logging.getLogger(__name__) + + async def send_ton_transfer( client: FragmentClient, destination: str, @@ -47,6 +51,11 @@ async def send_ton_transfer( except (TransactionError, WalletError): raise except Exception as exc: + logger.exception( + "Failed to send TON transfer to '%s' for %s nanotons", + destination, + amount, + ) raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc=exc)) from exc @@ -88,4 +97,9 @@ async def send_usdt_transfer( except (TransactionError, WalletError): raise except Exception as exc: + logger.exception( + "Failed to send USDT transfer to '%s' for %s base units", + destination, + usdt_amount, + ) raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc=exc)) from exc diff --git a/tests/004_test_stars.py b/tests/004_test_stars.py index 69428d7..5ab6fcc 100644 --- a/tests/004_test_stars.py +++ b/tests/004_test_stars.py @@ -165,6 +165,7 @@ async def test_giveaway_stars_success(client: FragmentClient) -> None: AsyncMock( side_effect=[ {"found": {"recipient": FAKE_RECIPIENT}}, + {}, {"req_id": FAKE_REQ_ID}, FAKE_TRANSACTION, ] @@ -187,6 +188,7 @@ async def test_giveaway_stars_passes_payment_method(client: FragmentClient) -> N call_mock = AsyncMock( side_effect=[ {"found": {"recipient": FAKE_RECIPIENT}}, + {}, {"req_id": FAKE_REQ_ID}, FAKE_TRANSACTION, ] @@ -199,7 +201,7 @@ async def test_giveaway_stars_passes_payment_method(client: FragmentClient) -> N ): await client.giveaway_stars("@channel", winners=3, amount=1000, payment_method="usdt_ton") - init_call = call_mock.await_args_list[1] + init_call = call_mock.await_args_list[2] assert init_call.args[0] == "initGiveawayStarsRequest" assert init_call.args[1]["payment_method"] == "usdt_ton" assert proc_mock.await_args is not None diff --git a/tests/005_test_premium.py b/tests/005_test_premium.py index 5ff8caf..15da529 100644 --- a/tests/005_test_premium.py +++ b/tests/005_test_premium.py @@ -150,6 +150,7 @@ async def test_giveaway_premium_success(client: FragmentClient) -> None: AsyncMock( side_effect=[ {"found": {"recipient": FAKE_RECIPIENT}}, + {}, {"req_id": FAKE_REQ_ID}, FAKE_TRANSACTION, ] @@ -172,6 +173,7 @@ async def test_giveaway_premium_passes_payment_method(client: FragmentClient) -> call_mock = AsyncMock( side_effect=[ {"found": {"recipient": FAKE_RECIPIENT}}, + {}, {"req_id": FAKE_REQ_ID}, FAKE_TRANSACTION, ] @@ -184,7 +186,7 @@ async def test_giveaway_premium_passes_payment_method(client: FragmentClient) -> ): await client.giveaway_premium("@channel", winners=10, months=6, payment_method="usdt_ton") - init_call = call_mock.await_args_list[1] + init_call = call_mock.await_args_list[2] assert init_call.args[0] == "initGiveawayPremiumRequest" assert init_call.args[1]["payment_method"] == "usdt_ton" assert proc_mock.await_args is not None