From e3706f01bf086901d922217cf31039b1a5f54037 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Thu, 5 Mar 2026 04:08:35 +0200 Subject: [PATCH 01/10] refactor: migrate to tonutils v2, pytest, pyproject.toml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tonutils 2.0.0: TonapiClient → ToncenterV3Client, wallet.transfer() - classes → plain async functions across all utils and methods - core/: constants.py, cookies.py, exceptions.py extracted - DEVICE constant in constants.py (single source of truth) - account/device serialised with json.dumps() in all tx payloads - tests: unittest → pytest, conftest.py fixtures, 001/002 naming - pyproject.toml: pytest + ruff config - min balance check: 0.056 TON --- .env.example | 4 +- .gitignore | 2 +- app/__meta__.py | 13 --- app/core/__init__.py | 34 ++++++- app/core/config.py | 42 ++++---- app/core/constants.py | 38 ++++++++ app/core/cookies.py | 35 +++++++ app/core/exceptions.py | 42 ++++++++ app/core/logging.py | 6 +- app/methods/__init__.py | 8 +- app/methods/premium.py | 205 ++++++++++++++++++--------------------- app/methods/stars.py | 191 +++++++++++++++++------------------- app/methods/ton.py | 203 ++++++++++++++++++-------------------- app/utils/__init__.py | 17 ++-- app/utils/client.py | 67 ++++++------- app/utils/cookies.py | 35 ------- app/utils/decoder.py | 34 ++----- app/utils/hash.py | 30 +++--- app/utils/transaction.py | 79 +++++++-------- app/utils/wallet.py | 73 ++++++++++---- main.py | 17 ++-- pyproject.toml | 17 ++++ requirements.txt | 4 +- 23 files changed, 631 insertions(+), 565 deletions(-) delete mode 100644 app/__meta__.py create mode 100644 app/core/constants.py create mode 100644 app/core/cookies.py create mode 100644 app/core/exceptions.py delete mode 100644 app/utils/cookies.py create mode 100644 pyproject.toml diff --git a/.env.example b/.env.example index 9abefcb..4b9fd93 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,5 @@ # Fragment.com cookies - copy from browser after login (Header String format) -# How to get fragment hash: open devtools -> Network -> api?hash= -# HASH = "your_fragment_hash_here" --- IGNORE --- -# Hash is now fetched dynamically, so this line is no longer needed. +# Hash is now fetched dynamically # TON wallet seed phrase - 12 or 24 words separated by spaces SEED = "your_ton_wallet_seed_phrase_here" diff --git a/.gitignore b/.gitignore index d8f5d48..0b63ba5 100644 --- a/.gitignore +++ b/.gitignore @@ -26,4 +26,4 @@ tests/ # System files .DS_Store -Thumbs.db \ No newline at end of file +Thumbs.db diff --git a/app/__meta__.py b/app/__meta__.py deleted file mode 100644 index f5240f6..0000000 --- a/app/__meta__.py +++ /dev/null @@ -1,13 +0,0 @@ -APP_NAME = "FragmentAPI" -APP_TITLE = "Fragment API by @bohd4nx" -APP_VERSION = "2025.1.2" -APP_AUTHOR = "Bohdan (bohd4nx)" -APP_TIMESTAMP = "2025-11-24T12:00:00Z" - -__all__ = [ - "APP_NAME", - "APP_TITLE", - "APP_VERSION", - "APP_AUTHOR", - "APP_TIMESTAMP", -] diff --git a/app/core/__init__.py b/app/core/__init__.py index 00a22a3..537895b 100644 --- a/app/core/__init__.py +++ b/app/core/__init__.py @@ -1,4 +1,34 @@ -from app.core.config import Config, config +from app.core.config import config +from app.core.constants import ADS_PAGE, BASE_HEADERS, DEVICE, PREMIUM_PAGE, STARS_PAGE +from app.core.cookies import load_cookies +from app.core.exceptions import ( + ConfigError, + CookiesError, + FragmentError, + HashFetchError, + RequestError, + TransactionError, + UserNotFoundError, + WalletError, +) from app.core.logging import logger, setup_logging -__all__ = ["Config", "config", "logger", "setup_logging"] +__all__ = [ + "ADS_PAGE", + "BASE_HEADERS", + "DEVICE", + "PREMIUM_PAGE", + "STARS_PAGE", + "ConfigError", + "CookiesError", + "FragmentError", + "HashFetchError", + "RequestError", + "TransactionError", + "UserNotFoundError", + "WalletError", + "config", + "load_cookies", + "logger", + "setup_logging", +] diff --git a/app/core/config.py b/app/core/config.py index 331989a..6ca9814 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -5,34 +5,38 @@ from pathlib import Path from dotenv import load_dotenv +from app.core.exceptions import ConfigError + logger = logging.getLogger(__name__) class Config: - def __init__(self): + SEED: str + API_KEY: str + + def __init__(self) -> None: env_path = Path(__file__).resolve().parents[2] / ".env" if not env_path.exists(): - logger.error(".env file not found!") - sys.exit(1) + raise ConfigError( + ".env file not found. " + "Copy .env.example to .env and fill in SEED and API_KEY." + ) load_dotenv(env_path) - required_keys = ["SEED", "API_KEY"] - missing_keys: list[str] = [] + missing = [k for k in ("SEED", "API_KEY") if not os.getenv(k, "").strip()] + if missing: + raise ConfigError( + f"Missing required environment variables: {', '.join(missing)}. " + "Open .env and fill in all required fields." + ) - for key in required_keys: - value = os.getenv(key, "").strip() - if not value: - missing_keys.append(key) - setattr(self, key, value) + self.SEED = os.getenv("SEED", "").strip() + self.API_KEY = os.getenv("API_KEY", "").strip() - if missing_keys: - logger.error(f"Missing required environment variables: {', '.join(missing_keys)}") - logger.error("Create .env file based on .env.example and fill all fields") - sys.exit(1) - - logger.info("Configuration loaded successfully") - - -config = Config() +try: + config = Config() +except ConfigError as e: + logger.error("Configuration error: %s", e) + sys.exit(1) diff --git a/app/core/constants.py b/app/core/constants.py new file mode 100644 index 0000000..700116f --- /dev/null +++ b/app/core/constants.py @@ -0,0 +1,38 @@ +import json + +# Fragment page URLs +STARS_PAGE: str = "https://fragment.com/stars/buy" +PREMIUM_PAGE: str = "https://fragment.com/premium/gift" +ADS_PAGE: str = "https://fragment.com/ads/topup" + +# Tonkeeper device fingerprint — serialized once, reused in every tx_data payload. +DEVICE: str = json.dumps({ + "platform": "iphone", + "appName": "Tonkeeper", + "appVersion": "5.5.2", + "maxProtocolVersion": 2, + "features": [ + "SendTransaction", + {"name": "SendTransaction", "maxMessages": 255}, + {"name": "SignData", "types": ["text", "binary", "cell"]}, + ], +}) + +# Base HTTP headers — shared across all Fragment API requests. +# Each method merges these with its own "referer" and "x-aj-referer". +BASE_HEADERS: dict[str, str] = { + "accept": "application/json, text/javascript, */*; q=0.01", + "accept-encoding": "gzip, deflate, br, zstd", + "accept-language": "en-US,en;q=0.9,uk;q=0.8,ru;q=0.7", + "content-type": "application/x-www-form-urlencoded; charset=UTF-8", + "origin": "https://fragment.com", + "priority": "u=1, i", + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", + "user-agent": ( + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) " + "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1" + ), + "x-requested-with": "XMLHttpRequest", +} diff --git a/app/core/cookies.py b/app/core/cookies.py new file mode 100644 index 0000000..7dc4031 --- /dev/null +++ b/app/core/cookies.py @@ -0,0 +1,35 @@ +import json +import logging +from pathlib import Path +from typing import Any + +from app.core.exceptions import CookiesError + +logger = logging.getLogger(__name__) + +_REQUIRED_KEYS = ("stel_ssid", "stel_dt", "stel_token", "stel_ton_token") + + +def load_cookies() -> dict[str, Any]: + cookies_path = Path(__file__).resolve().parents[2] / "cookies.json" + + if not cookies_path.exists(): + raise CookiesError( + "cookies.json not found. " + "Create it in the project root and paste your Fragment cookies." + ) + + try: + with cookies_path.open("r", encoding="utf-8") as f: + cookies = json.load(f) + except Exception as exc: + raise CookiesError(f"Failed to read cookies.json: {exc}") from exc + + missing = [k for k in _REQUIRED_KEYS if not str(cookies.get(k, "")).strip()] + if missing: + raise CookiesError( + f"cookies.json is missing or has empty values for: {', '.join(missing)}. " + "Open Fragment.com in your browser, copy fresh cookies, and update the file." + ) + + return cookies diff --git a/app/core/exceptions.py b/app/core/exceptions.py new file mode 100644 index 0000000..9a59b65 --- /dev/null +++ b/app/core/exceptions.py @@ -0,0 +1,42 @@ +__all__ = [ + "ConfigError", + "CookiesError", + "FragmentError", + "HashFetchError", + "RequestError", + "TransactionError", + "UserNotFoundError", + "WalletError", +] + + +class FragmentError(Exception): + """Base exception for all Fragment API errors.""" + + +class ConfigError(FragmentError): + """Raised when .env is missing or required keys are absent.""" + + +class CookiesError(FragmentError): + """Raised when cookies.json is missing, unreadable, or has empty required fields.""" + + +class HashFetchError(FragmentError): + """Raised when the Fragment API hash cannot be fetched from the page.""" + + +class UserNotFoundError(FragmentError): + """Raised when the target Telegram user is not found on Fragment.""" + + +class WalletError(FragmentError): + """Raised for TON wallet issues (connection, balance, account info).""" + + +class TransactionError(FragmentError): + """Raised when a TON transaction fails to build or broadcast.""" + + +class RequestError(FragmentError): + """Raised when a Fragment API response cannot be parsed.""" diff --git a/app/core/logging.py b/app/core/logging.py index 2f38067..87be637 100644 --- a/app/core/logging.py +++ b/app/core/logging.py @@ -1,7 +1,5 @@ import logging -from app.__meta__ import APP_NAME - def setup_logging() -> None: formatter = logging.Formatter( @@ -14,7 +12,7 @@ def setup_logging() -> None: console_handler.setFormatter(formatter) file_handler = logging.FileHandler( - f"{APP_NAME}.log", + "FragmentAPI.log", mode="w", encoding="utf-8" ) @@ -26,8 +24,6 @@ def setup_logging() -> None: force=True ) - logging.getLogger("aiogram.dispatcher").setLevel(logging.INFO) - logging.getLogger("aiogram.event").setLevel(logging.ERROR) logging.getLogger("httpx").setLevel(logging.INFO) logging.getLogger("httpcore").setLevel(logging.WARNING) diff --git a/app/methods/__init__.py b/app/methods/__init__.py index fc72eef..ba0e43a 100644 --- a/app/methods/__init__.py +++ b/app/methods/__init__.py @@ -1,5 +1,5 @@ -from app.methods.premium import FragmentPremium -from app.methods.stars import FragmentStars -from app.methods.ton import FragmentTon +from app.methods.premium import buy_premium +from app.methods.stars import buy_stars +from app.methods.ton import topup_ton -__all__ = ['FragmentTon', 'FragmentPremium', 'FragmentStars'] +__all__ = ['buy_premium', 'buy_stars', 'topup_ton'] diff --git a/app/methods/premium.py b/app/methods/premium.py index 21a1772..bfde21c 100644 --- a/app/methods/premium.py +++ b/app/methods/premium.py @@ -1,133 +1,116 @@ -import base64 +import json import logging import time import httpx -from tonutils.client import TonapiClient -from tonutils.wallet import WalletV5R1 -from app.core import config +from app.core import load_cookies +from app.core.constants import BASE_HEADERS, DEVICE, PREMIUM_PAGE +from app.core.exceptions import FragmentError, UserNotFoundError from app.utils import ( - TransactionProcessor, - WalletLinker, - ApiClient, - clean_decode, - parse_json_response, - load_cookies, + execute_transaction_request, + get_account_info, get_fragment_hash, + parse_json_response, + process_transaction, ) logger = logging.getLogger(__name__) +# Page-specific headers +HEADERS: dict[str, str] = { + **BASE_HEADERS, + "referer": PREMIUM_PAGE, + "x-aj-referer": PREMIUM_PAGE, +} -class FragmentPremium: - def __init__(self): - self.headers = { - "accept": "application/json, text/javascript, */*; q=0.01", - "accept-encoding": "gzip, deflate, br, zstd", - "accept-language": "en-US,en;q=0.9,uk;q=0.8,ru;q=0.7", - "content-type": "application/x-www-form-urlencoded; charset=UTF-8", - "origin": "https://fragment.com", - "referer": "https://fragment.com/premium/gift", - "user-agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1", - "x-requested-with": "XMLHttpRequest", - } - self.cookies = load_cookies() - - self.transaction_processor = TransactionProcessor(clean_decode) - self.wallet_linker = WalletLinker(self.headers, self.cookies, self.transaction_processor) - self.api_client = ApiClient(self.headers, self.cookies, self.wallet_linker) - - @staticmethod - async def _get_account_info(): - client = TonapiClient(api_key=config.API_KEY, is_testnet=False) - wallet, pub_key, _, _ = WalletV5R1.from_mnemonic(client=client, mnemonic=config.SEED) - boc = wallet.state_init.serialize().to_boc() - - return { - "address": wallet.address.to_str(False, False), - "publicKey": pub_key.hex(), - "chain": "-239", - "walletStateInit": base64.b64encode(boc).decode() - } - - async def buy_premium(self, username, months): - if months not in [3, 6, 12]: - return {"success": False, "error": "Invalid duration. Use 3, 6, or 12 months"} - - fragment_hash = await get_fragment_hash( - self.cookies, - self.headers, - "https://fragment.com/premium/gift", +async def search_premium_recipient( + client: httpx.AsyncClient, + fragment_hash: str, + cookies: dict, + username: str, + months: int, +) -> str: + resp = await client.post( + f"https://fragment.com/api?hash={fragment_hash}", + headers=HEADERS, cookies=cookies, + data={"query": username, "months": months, "method": "searchPremiumGiftRecipient"}, + ) + result = parse_json_response(resp, "searchPremiumGiftRecipient") + recipient = result.get("found", {}).get("recipient") + if not recipient: + raise UserNotFoundError( + f"Telegram user '{username}' was not found on Fragment. " + "Make sure the username is correct and the account exists." ) - if not fragment_hash: - raise RuntimeError("Failed to fetch Fragment hash") + return recipient - account = await self._get_account_info() + +async def init_gift_premium( + client: httpx.AsyncClient, + fragment_hash: str, + cookies: dict, + recipient: str, + months: int, +) -> str: + await client.post( + f"https://fragment.com/api?hash={fragment_hash}", + headers=HEADERS, cookies=cookies, + data={"mode": "new", "lv": "false", "dh": str(int(time.time())), "method": "updatePremiumState"}, + ) + resp = await client.post( + f"https://fragment.com/api?hash={fragment_hash}", + headers=HEADERS, cookies=cookies, + data={"recipient": recipient, "months": months, "method": "initGiftPremiumRequest"}, + ) + result = parse_json_response(resp, "initGiftPremiumRequest") + req_id = result.get("req_id") + if not req_id: + raise FragmentError( + "Fragment did not return a request ID for this Premium purchase. " + "The session may have expired — refresh your cookies." + ) + return req_id + + +async def buy_premium(username: str, months: int) -> dict: + if months not in (3, 6, 12): + return {"success": False, "error": "Invalid duration. Choose 3, 6, or 12 months."} + + try: + cookies = load_cookies() + fragment_hash = await get_fragment_hash(cookies, HEADERS, PREMIUM_PAGE) + account = await get_account_info() async with httpx.AsyncClient() as client: - search_data = {"query": username, "months": months, "method": "searchPremiumGiftRecipient"} - search_resp = await client.post(f"https://fragment.com/api?hash={fragment_hash}", - headers=self.headers, cookies=self.cookies, data=search_data) - - search_result, error = parse_json_response(search_resp, logger, "search") - if search_result is None: - return {"success": False, "error": f"Invalid response from Fragment API: {error}"} - - recipient = search_result.get("found", {}).get("recipient") - if not recipient: - return {"success": False, "error": "User not found"} - - update_data = {"mode": "new", "lv": "false", "dh": str(int(time.time())), "method": "updatePremiumState"} - await client.post(f"https://fragment.com/api?hash={fragment_hash}", - headers=self.headers, cookies=self.cookies, data=update_data) - - init_data = {"recipient": recipient, "months": months, "method": "initGiftPremiumRequest"} - init_resp = await client.post(f"https://fragment.com/api?hash={fragment_hash}", - headers=self.headers, cookies=self.cookies, data=init_data) - - init_result, error = parse_json_response(init_resp, logger, "init") - if init_result is None: - return {"success": False, "error": f"Invalid response from Fragment API: {error}"} - - req_id = init_result.get("req_id") - if not req_id: - return {"success": False, "error": "Failed to initialize purchase"} + recipient = await search_premium_recipient(client, fragment_hash, cookies, username, months) + req_id = await init_gift_premium(client, fragment_hash, cookies, recipient, months) tx_data = { - 'account': account, - 'device': {"appVersion": "5.4.3", "platform": "iphone", - "features": ["SendTransaction", {"maxMessages": 255, "name": "SendTransaction"}, - {"types": ["text", "binary", "cell"], "name": "SignData"}], - "appName": "Tonkeeper", "maxProtocolVersion": 2}, - 'transaction': 1, - 'id': req_id, - 'show_sender': 1, - 'ref': "OprzztcdJ", - 'method': 'getGiftPremiumLink' + "account": json.dumps(account), + "device": DEVICE, + "transaction": 1, + "id": req_id, + "show_sender": 1, + "method": "getGiftPremiumLink", } + transaction = await execute_transaction_request(client, HEADERS, cookies, account, tx_data, fragment_hash) - request_success, transaction_result = await self.api_client.execute_transaction_request( - tx_data, - account, - fragment_hash, - ) + tx_hash = await process_transaction(transaction) + return { + "success": True, + "data": { + "transaction_id": tx_hash, + "username": username, + "months": months, + "timestamp": int(time.time()), + }, + } - if not request_success: - return transaction_result - - success, error, tx_hash = await self.transaction_processor.process_transaction(transaction_result) - - if success: - return { - "success": True, - "data": { - "transaction_id": tx_hash, - "username": username, - "months": months, - "timestamp": int(time.time()) - } - } - - return {"success": False, "error": error} + except FragmentError as exc: + logger.error("Premium purchase failed — %s", exc) + return {"success": False, "error": str(exc)} + except Exception as exc: + logger.exception("Unexpected error during Premium purchase") + return {"success": False, "error": f"Unexpected error: {exc}"} diff --git a/app/methods/stars.py b/app/methods/stars.py index 8be0e82..d9f578b 100644 --- a/app/methods/stars.py +++ b/app/methods/stars.py @@ -1,125 +1,110 @@ -import base64 +import json import logging import time import httpx -from tonutils.client import TonapiClient -from tonutils.wallet import WalletV5R1 -from app.core import config +from app.core import load_cookies +from app.core.constants import BASE_HEADERS, DEVICE, STARS_PAGE +from app.core.exceptions import FragmentError, UserNotFoundError from app.utils import ( - TransactionProcessor, - WalletLinker, - ApiClient, - clean_decode, - parse_json_response, - load_cookies, + execute_transaction_request, + get_account_info, get_fragment_hash, + parse_json_response, + process_transaction, ) logger = logging.getLogger(__name__) +# Page-specific headers +HEADERS: dict[str, str] = { + **BASE_HEADERS, + "referer": STARS_PAGE, + "x-aj-referer": STARS_PAGE, +} -class FragmentStars: - def __init__(self): - self.headers = { - "accept": "application/json, text/javascript, */*; q=0.01", - "accept-encoding": "gzip, deflate, br, zstd", - "accept-language": "en-US,en;q=0.9,uk;q=0.8,ru;q=0.7", - "content-type": "application/x-www-form-urlencoded; charset=UTF-8", - "origin": "https://fragment.com", - "referer": "https://fragment.com/stars/buy", - "user-agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1", - "x-requested-with": "XMLHttpRequest", - } - self.cookies = load_cookies() - - self.transaction_processor = TransactionProcessor(clean_decode) - self.wallet_linker = WalletLinker(self.headers, self.cookies, self.transaction_processor) - self.api_client = ApiClient(self.headers, self.cookies, self.wallet_linker) - - @staticmethod - async def _get_account_info(): - client = TonapiClient(api_key=config.API_KEY, is_testnet=False) - wallet, pub_key, _, _ = WalletV5R1.from_mnemonic(client=client, mnemonic=config.SEED) - boc = wallet.state_init.serialize().to_boc() - - return { - "address": wallet.address.to_str(False, False), - "publicKey": pub_key.hex(), - "chain": "-239", - "walletStateInit": base64.b64encode(boc).decode() - } - - async def buy_stars(self, username, amount): - if amount < 50 or not isinstance(amount, int): - return {"success": False, "error": "Amount must be an integer >= 50 stars"} - - fragment_hash = await get_fragment_hash( - self.cookies, - self.headers, - "https://fragment.com/stars/buy", +async def search_stars_recipient( + client: httpx.AsyncClient, + fragment_hash: str, + cookies: dict, + username: str, +) -> str: + resp = await client.post( + f"https://fragment.com/api?hash={fragment_hash}", + headers=HEADERS, cookies=cookies, + data={"query": username, "quantity": "", "method": "searchStarsRecipient"}, + ) + result = parse_json_response(resp, "searchStarsRecipient") + recipient = result.get("found", {}).get("recipient") + if not recipient: + raise UserNotFoundError( + f"Telegram user '{username}' was not found on Fragment. " + "Make sure the username is correct and the account exists." ) - if not fragment_hash: - raise RuntimeError("Failed to fetch Fragment hash") + return recipient - account = await self._get_account_info() + +async def init_buy_stars( + client: httpx.AsyncClient, + fragment_hash: str, + cookies: dict, + recipient: str, + amount: int, +) -> str: + resp = await client.post( + f"https://fragment.com/api?hash={fragment_hash}", + headers=HEADERS, cookies=cookies, + data={"recipient": recipient, "quantity": amount, "method": "initBuyStarsRequest"}, + ) + result = parse_json_response(resp, "initBuyStarsRequest") + req_id = result.get("req_id") + if not req_id: + raise FragmentError( + "Fragment did not return a request ID for this Stars purchase. " + "The session may have expired — refresh your cookies." + ) + return req_id + + +async def buy_stars(username: str, amount: int) -> dict: + if not isinstance(amount, int) or amount < 50: + return {"success": False, "error": "Amount must be an integer >= 50 stars."} + + try: + cookies = load_cookies() + fragment_hash = await get_fragment_hash(cookies, HEADERS, STARS_PAGE) + account = await get_account_info() async with httpx.AsyncClient() as client: - search_data = {"query": username, "quantity": "", "method": "searchStarsRecipient"} - search_resp = await client.post(f"https://fragment.com/api?hash={fragment_hash}", - headers=self.headers, cookies=self.cookies, data=search_data) - - search_result, error = parse_json_response(search_resp, logger, "search") - if search_result is None: - return {"success": False, "error": f"Invalid response from Fragment API: {error}"} - - recipient = search_result.get("found", {}).get("recipient") - if not recipient: - return {"success": False, "error": "User not found"} - - init_data = {"recipient": recipient, "quantity": amount, "method": "initBuyStarsRequest"} - init_resp = await client.post(f"https://fragment.com/api?hash={fragment_hash}", - headers=self.headers, cookies=self.cookies, data=init_data) - - init_result, error = parse_json_response(init_resp, logger, "init") - if init_result is None: - return {"success": False, "error": f"Invalid response from Fragment API: {error}"} - - req_id = init_result.get("req_id") - if not req_id: - return {"success": False, "error": "Failed to initialize purchase"} + recipient = await search_stars_recipient(client, fragment_hash, cookies, username) + req_id = await init_buy_stars(client, fragment_hash, cookies, recipient, amount) tx_data = { - 'account': account, - 'device': "iPhone15,2", - 'transaction': 1, - 'id': req_id, - 'show_sender': 0, - 'method': 'getBuyStarsLink' + "account": json.dumps(account), + "device": DEVICE, + "transaction": 1, + "id": req_id, + "show_sender": 1, + "method": "getBuyStarsLink", } + transaction = await execute_transaction_request(client, HEADERS, cookies, account, tx_data, fragment_hash) - request_success, transaction_result = await self.api_client.execute_transaction_request( - tx_data, - account, - fragment_hash, - ) + tx_hash = await process_transaction(transaction) + return { + "success": True, + "data": { + "transaction_id": tx_hash, + "username": username, + "amount": amount, + "timestamp": int(time.time()), + }, + } - if not request_success: - return transaction_result - - success, error, tx_hash = await self.transaction_processor.process_transaction(transaction_result) - - if success: - return { - "success": True, - "data": { - "transaction_id": tx_hash, - "username": username, - "amount": amount, - "timestamp": int(time.time()) - } - } - - return {"success": False, "error": error} + except FragmentError as exc: + logger.error("Stars purchase failed — %s", exc) + return {"success": False, "error": str(exc)} + except Exception as exc: + logger.exception("Unexpected error during Stars purchase") + return {"success": False, "error": f"Unexpected error: {exc}"} diff --git a/app/methods/ton.py b/app/methods/ton.py index 596678b..8478433 100644 --- a/app/methods/ton.py +++ b/app/methods/ton.py @@ -1,132 +1,115 @@ -import base64 +import json import logging import time import httpx -from tonutils.client import TonapiClient -from tonutils.wallet import WalletV5R1 -from app.core import config +from app.core import load_cookies +from app.core.constants import ADS_PAGE, BASE_HEADERS, DEVICE +from app.core.exceptions import FragmentError, UserNotFoundError from app.utils import ( - TransactionProcessor, - WalletLinker, - ApiClient, - clean_decode, - parse_json_response, - load_cookies, + execute_transaction_request, + get_account_info, get_fragment_hash, + parse_json_response, + process_transaction, ) logger = logging.getLogger(__name__) +# Page-specific headers +HEADERS: dict[str, str] = { + **BASE_HEADERS, + "referer": ADS_PAGE, + "x-aj-referer": ADS_PAGE, +} -class FragmentTon: - def __init__(self): - self.headers = { - "accept": "application/json, text/javascript, */*; q=0.01", - "accept-encoding": "gzip, deflate, br, zstd", - "accept-language": "en-US,en;q=0.9,uk;q=0.8,ru;q=0.7", - "content-type": "application/x-www-form-urlencoded; charset=UTF-8", - "origin": "https://fragment.com", - "referer": "https://fragment.com/ads/topup", - "user-agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1", - "x-requested-with": "XMLHttpRequest", - } - self.cookies = load_cookies() - - self.transaction_processor = TransactionProcessor(clean_decode) - self.wallet_linker = WalletLinker(self.headers, self.cookies, self.transaction_processor) - self.api_client = ApiClient(self.headers, self.cookies, self.wallet_linker) - - @staticmethod - async def _get_account_info(): - client = TonapiClient(api_key=config.API_KEY, is_testnet=False) - wallet, pub_key, _, _ = WalletV5R1.from_mnemonic(client=client, mnemonic=config.SEED) - boc = wallet.state_init.serialize().to_boc() - - return { - "address": wallet.address.to_str(False, False), - "publicKey": pub_key.hex(), - "chain": "-239", - "walletStateInit": base64.b64encode(boc).decode() - } - - async def topup_ton(self, username, amount): - if amount < 1 or not isinstance(amount, int): - return {"success": False, "error": "Amount must be an integer >= 1 TON"} - - fragment_hash = await get_fragment_hash( - self.cookies, - self.headers, - "https://fragment.com/ads/topup", +async def search_ads_recipient( + client: httpx.AsyncClient, + fragment_hash: str, + cookies: dict, + username: str, +) -> str: + await client.post( + f"https://fragment.com/api?hash={fragment_hash}", + headers=HEADERS, cookies=cookies, + data={"mode": "new", "method": "updateAdsTopupState"}, + ) + resp = await client.post( + f"https://fragment.com/api?hash={fragment_hash}", + headers=HEADERS, cookies=cookies, + data={"query": username, "method": "searchAdsTopupRecipient"}, + ) + result = parse_json_response(resp, "searchAdsTopupRecipient") + recipient = result.get("found", {}).get("recipient") + if not recipient: + raise UserNotFoundError( + f"Telegram user '{username}' was not found on Fragment. " + "Make sure the username is correct and the account exists." ) - if not fragment_hash: - raise RuntimeError("Failed to fetch Fragment hash") + return recipient - account = await self._get_account_info() + +async def init_ads_topup( + client: httpx.AsyncClient, + fragment_hash: str, + cookies: dict, + recipient: str, + amount: int, +) -> str: + resp = await client.post( + f"https://fragment.com/api?hash={fragment_hash}", + headers=HEADERS, cookies=cookies, + data={"recipient": recipient, "amount": amount, "method": "initAdsTopupRequest"}, + ) + result = parse_json_response(resp, "initAdsTopupRequest") + req_id = result.get("req_id") + if not req_id: + raise FragmentError( + "Fragment did not return a request ID for this TON topup. " + "The session may have expired — refresh your cookies." + ) + return req_id + + +async def topup_ton(username: str, amount: int) -> dict: + if not isinstance(amount, int) or amount < 1: + return {"success": False, "error": "Amount must be an integer >= 1 TON."} + + try: + cookies = load_cookies() + fragment_hash = await get_fragment_hash(cookies, HEADERS, ADS_PAGE) + account = await get_account_info() async with httpx.AsyncClient() as client: - update_data = {"mode": "new", "method": "updateAdsTopupState"} - await client.post(f"https://fragment.com/api?hash={fragment_hash}", - headers=self.headers, cookies=self.cookies, data=update_data) - - search_data = {"query": username, "method": "searchAdsTopupRecipient"} - search_resp = await client.post(f"https://fragment.com/api?hash={fragment_hash}", - headers=self.headers, cookies=self.cookies, data=search_data) - - search_result, error = parse_json_response(search_resp, logger, "search") - if search_result is None: - return {"success": False, "error": f"Invalid response from Fragment API: {error}"} - - recipient = search_result.get("found", {}).get("recipient") - if not recipient: - return {"success": False, "error": "User not found"} - - init_data = {"recipient": recipient, "amount": amount, "method": "initAdsTopupRequest"} - init_resp = await client.post(f"https://fragment.com/api?hash={fragment_hash}", - headers=self.headers, cookies=self.cookies, data=init_data) - - init_result, error = parse_json_response(init_resp, logger, "init") - if init_result is None: - return {"success": False, "error": f"Invalid response from Fragment API: {error}"} - - req_id = init_result.get("req_id") - if not req_id: - return {"success": False, "error": "Failed to initialize topup"} + recipient = await search_ads_recipient(client, fragment_hash, cookies, username) + req_id = await init_ads_topup(client, fragment_hash, cookies, recipient, amount) tx_data = { - 'account': account, - 'device': {"appVersion": "5.4.3", "platform": "iphone", - "features": ["SendTransaction", {"maxMessages": 255, "name": "SendTransaction"}, - {"types": ["text", "binary", "cell"], "name": "SignData"}], - "appName": "Tonkeeper", "maxProtocolVersion": 2}, - 'transaction': 1, - 'id': req_id, - 'show_sender': 1, - 'method': 'getAdsTopupLink' + "account": json.dumps(account), + "device": DEVICE, + "transaction": 1, + "id": req_id, + "show_sender": 1, + "method": "getAdsTopupLink", } + transaction = await execute_transaction_request(client, HEADERS, cookies, account, tx_data, fragment_hash) - request_success, transaction_result = await self.api_client.execute_transaction_request( - tx_data, - account, - fragment_hash, - ) + tx_hash = await process_transaction(transaction) + return { + "success": True, + "data": { + "transaction_id": tx_hash, + "username": username, + "amount": amount, + "timestamp": int(time.time()), + }, + } - if not request_success: - return transaction_result - - success, error, tx_hash = await self.transaction_processor.process_transaction(transaction_result) - - if success: - return { - "success": True, - "data": { - "transaction_id": tx_hash, - "username": username, - "amount": amount, - "timestamp": int(time.time()) - } - } - - return {"success": False, "error": error} + except FragmentError as exc: + logger.error("TON topup failed — %s", exc) + return {"success": False, "error": str(exc)} + except Exception as exc: + logger.exception("Unexpected error during TON topup") + return {"success": False, "error": f"Unexpected error: {exc}"} diff --git a/app/utils/__init__.py b/app/utils/__init__.py index 9937bf4..6ead284 100644 --- a/app/utils/__init__.py +++ b/app/utils/__init__.py @@ -1,16 +1,15 @@ -from app.utils.client import ApiClient, parse_json_response -from app.utils.cookies import load_cookies +from app.utils.client import execute_transaction_request, parse_json_response from app.utils.decoder import clean_decode from app.utils.hash import get_fragment_hash -from app.utils.transaction import TransactionProcessor -from app.utils.wallet import WalletLinker +from app.utils.transaction import process_transaction +from app.utils.wallet import get_account_info, link_wallet __all__ = [ - 'TransactionProcessor', - 'WalletLinker', - 'ApiClient', 'clean_decode', + 'execute_transaction_request', + 'get_account_info', + 'get_fragment_hash', + 'link_wallet', 'parse_json_response', - 'load_cookies', - 'get_fragment_hash' + 'process_transaction', ] diff --git a/app/utils/client.py b/app/utils/client.py index 808fd9a..520a7d7 100644 --- a/app/utils/client.py +++ b/app/utils/client.py @@ -3,47 +3,42 @@ from typing import Any import httpx +from app.core.exceptions import RequestError, WalletError +from app.utils.wallet import link_wallet -def parse_json_response( - response: httpx.Response, - logger: logging.Logger, - context: str, -) -> tuple[dict[str, Any] | None, str | None]: +logger = logging.getLogger(__name__) + + +def parse_json_response(response: httpx.Response, context: str) -> dict[str, Any]: try: - return response.json(), None - except Exception as e: - logger.error(f"Failed to parse {context} response: {e}") - logger.error(f"Response content: {response.content[:200]}") - return None, str(e) + return response.json() + except Exception as exc: + raise RequestError( + f"Fragment API returned an unparseable response for '{context}': {exc}" + ) from exc -class ApiClient: - def __init__(self, headers: dict, cookies: dict, wallet_linker): - self.headers = headers - self.cookies = cookies - self.wallet_linker = wallet_linker +async def execute_transaction_request( + client: httpx.AsyncClient, + headers: dict, + cookies: dict, + account: dict[str, Any], + tx_data: dict[str, Any], + fragment_hash: str, +) -> dict[str, Any]: + url = f"https://fragment.com/api?hash={fragment_hash}" - async def execute_transaction_request( - self, - tx_data: dict[str, Any], - account: dict[str, Any], - fragment_hash: str, - ) -> tuple[bool, dict[str, Any]]: - async with httpx.AsyncClient() as client: - tx_resp = await client.post(f"https://fragment.com/api?hash={fragment_hash}", - headers=self.headers, cookies=self.cookies, data=tx_data) - transaction, error = parse_json_response(tx_resp, logging.getLogger(__name__), "transaction") - if transaction is None: - return False, {"success": False, "error": f"Invalid response from Fragment API: {error}"} + resp = await client.post(url, headers=headers, cookies=cookies, data=tx_data) + transaction = parse_json_response(resp, tx_data.get("method", "transaction")) - if transaction.get("need_verify"): - if not await self.wallet_linker.link_wallet(account, fragment_hash): - return False, {"success": False, "error": "Failed to link wallet"} + if transaction.get("need_verify"): + if not await link_wallet(client, headers, cookies, account, fragment_hash): + raise WalletError( + "Failed to link your TON wallet to Fragment. " + "Make sure the wallet matching your cookies is used." + ) + resp = await client.post(url, headers=headers, cookies=cookies, data=tx_data) + transaction = parse_json_response(resp, tx_data.get("method", "transaction")) - tx_resp = await client.post(f"https://fragment.com/api?hash={fragment_hash}", - headers=self.headers, cookies=self.cookies, data=tx_data) - transaction, error = parse_json_response(tx_resp, logging.getLogger(__name__), "transaction") - if transaction is None: - return False, {"success": False, "error": f"Invalid response from Fragment API: {error}"} + return transaction - return True, transaction diff --git a/app/utils/cookies.py b/app/utils/cookies.py deleted file mode 100644 index ca6c25a..0000000 --- a/app/utils/cookies.py +++ /dev/null @@ -1,35 +0,0 @@ -import json -import logging -from pathlib import Path -from typing import Any - -logger = logging.getLogger(__name__) - - -def load_cookies() -> dict[str, Any]: - cookies_path = Path(__file__).resolve().parents[2] / "cookies.json" - - if not cookies_path.exists(): - logger.error("cookies.json file not found!") - return {} - - try: - with cookies_path.open("r", encoding="utf-8") as file: - cookies = json.load(file) - - required_keys = ["stel_ssid", "stel_dt", "stel_token", "stel_ton_token"] - missing_or_empty = [ - key for key in required_keys - if not str(cookies.get(key, "")).strip() - ] - - if missing_or_empty: - logger.warning( - "cookies.json has missing or empty values: %s", - ", ".join(missing_or_empty) - ) - - return cookies - except Exception as exc: - logger.error(f"Failed to load cookies.json: {exc}") - return {} diff --git a/app/utils/decoder.py b/app/utils/decoder.py index a1093f8..1a0656a 100644 --- a/app/utils/decoder.py +++ b/app/utils/decoder.py @@ -7,34 +7,20 @@ logger = logging.getLogger(__name__) def clean_decode(payload: str) -> str: - logger.debug(f"Original payload: {payload}") + logger.debug("Original payload: %s", payload) - # Decode raw bytes first - clean = ''.join(c for c in payload if c.isalnum() or c in '+/=') - clean += '=' * (-len(clean) % 4) - raw_bytes = base64.b64decode(clean) - logger.debug(f"Raw decoded bytes: {raw_bytes}") - - # 1. Clean Base64 + # Strip non-Base64 chars and decode s = re.sub(r'[^A-Za-z0-9+/=]', '', payload.strip()) s += '=' * (-len(s) % 4) + text = base64.b64decode(s).decode('utf-8', errors='ignore') - # 2. Base64 -> bytes - b = base64.b64decode(s) + # Keep only printable characters + text = ''.join(c for c in text if c in string.printable or c.isspace()) - # 3. Decode UTF-8, ignoring invalid bytes - t = b.decode('utf-8', errors='ignore') - - # 4. Remove binary characters, keep only printable + whitespace - t = ''.join(c for c in t if c in string.printable or c.isspace()) - - # 5. Extract the main text with Ref# - match = re.search(r'([0-9]*\s*Telegram .*?Ref#[A-Za-z0-9]+)', t, re.S) - if match: - result = match.group(1).strip() - else: - result = t.strip() - - logger.debug(f"Cleaned result: {result}") + # Extract "Telegram … Ref#XXXX" block + match = re.search(r'([0-9]*\s*Telegram .*?Ref#[A-Za-z0-9]+)', text, re.S) + result = match.group(1).strip() if match else text.strip() + logger.debug("Decoded result: %s", result) return result + diff --git a/app/utils/hash.py b/app/utils/hash.py index 5414ff1..8b5113c 100644 --- a/app/utils/hash.py +++ b/app/utils/hash.py @@ -4,6 +4,8 @@ from typing import Any import httpx +from app.core.exceptions import HashFetchError + logger = logging.getLogger(__name__) @@ -11,25 +13,27 @@ async def get_fragment_hash( cookies: dict[str, Any], headers: dict[str, str], page_url: str, -) -> str | None: - request_headers = { +) -> str: + page_headers = { + **headers, "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", - "accept-language": headers.get("accept-language") or headers.get("Accept-Language", "en-US,en;q=0.9"), - "user-agent": headers.get("user-agent") or headers.get("User-Agent", ""), "referer": "https://fragment.com/", } async with httpx.AsyncClient(cookies=cookies) as client: - response = await client.get(page_url, headers=request_headers) + response = await client.get(page_url, headers=page_headers) if response.status_code != 200: - logger.error("Failed to fetch Fragment page for hash: %s", response.status_code) - return None + raise HashFetchError( + f"Fragment returned HTTP {response.status_code} for {page_url}. " + "Check that your cookies are valid and not expired." + ) - text = response.text - match = re.search(r"(?:https://fragment\.com)?/api\?hash=([a-f0-9]+)", text) - if match: - return match.group(1) + match = re.search(r"(?:https://fragment\.com)?/api\?hash=([a-f0-9]+)", response.text) + if not match: + raise HashFetchError( + f"Fragment hash not found in the page source of {page_url}. " + "The page structure may have changed or you are not logged in." + ) - logger.error("Failed to extract Fragment hash from page") - return None + return match.group(1) diff --git a/app/utils/transaction.py b/app/utils/transaction.py index 63b4655..4df802c 100644 --- a/app/utils/transaction.py +++ b/app/utils/transaction.py @@ -1,58 +1,49 @@ import logging -from tonutils.client import TonapiClient, ToncenterV3Client +from tonutils.client import ToncenterV3Client from tonutils.wallet import WalletV5R1 -from tonutils.wallet.messages import TransferMessage from app.core import config +from app.core.exceptions import TransactionError, WalletError +from app.utils.decoder import clean_decode logger = logging.getLogger(__name__) -class TransactionProcessor: - def __init__(self, clean_decode_func): - self._clean_decode = clean_decode_func +async def process_transaction(transaction_data: dict) -> str: + if "transaction" not in transaction_data or "messages" not in transaction_data["transaction"]: + raise TransactionError( + "Fragment returned an invalid transaction payload. " + "The API response is missing expected 'transaction.messages' data." + ) - @staticmethod - async def _check_wallet_balance() -> tuple[bool, str | None]: - client = ToncenterV3Client(is_testnet=False, rps=1, max_retries=1) - wallet, _, _, _ = WalletV5R1.from_mnemonic(client=client, mnemonic=config.SEED) + client = ToncenterV3Client(api_key=config.API_KEY, is_testnet=False) + wallet, _, _, _ = WalletV5R1.from_mnemonic(client=client, mnemonic=config.SEED) - try: - balance = await wallet.balance() - except Exception as exc: - return False, f"Wallet balance check failed: {exc}" + # Check balance before broadcasting + try: + balance = await wallet.balance() + if float(balance) < 0.056: + raise WalletError( + f"TON wallet balance is too low: {balance} TON. " + "Minimum required is 0.056 TON." + ) + except WalletError: + raise + except Exception as exc: + raise WalletError(f"Wallet balance check failed: {exc}") from exc - try: - if float(balance) <= 0: - return False, "Wallet balance is zero" - except Exception: - pass + try: + message = transaction_data["transaction"]["messages"][0] + payload = clean_decode(message["payload"]) - return True, None + return await wallet.transfer( + destination=message["address"], + amount=int(message["amount"]) / 1_000_000_000, + body=payload, + ) + except (WalletError, TransactionError): + raise + except Exception as exc: + raise TransactionError(f"Transaction broadcast failed: {exc}") from exc - async def process_transaction(self, transaction_data: dict) -> tuple[bool, str | None, str | None]: - if "transaction" not in transaction_data or "messages" not in transaction_data["transaction"]: - return False, "Invalid transaction", None - - ready, reason = await self._check_wallet_balance() - if not ready: - return False, reason or "Wallet is not ready", None - - client = TonapiClient(api_key=config.API_KEY, is_testnet=False) - wallet, _, _, _ = WalletV5R1.from_mnemonic(client=client, mnemonic=config.SEED) - - try: - message = transaction_data["transaction"]["messages"][0] - payload = self._clean_decode(message["payload"]) - - messages = [TransferMessage( - destination=message["address"], - amount=int(message["amount"]) / 1000000000, - body=payload - )] - - tx_hash = await wallet.batch_transfer_messages(messages=messages) - return True, None, tx_hash - except Exception as e: - return False, str(e), None diff --git a/app/utils/wallet.py b/app/utils/wallet.py index 7bf4b0f..6b8dbc9 100644 --- a/app/utils/wallet.py +++ b/app/utils/wallet.py @@ -1,31 +1,62 @@ +import base64 +import json +import logging from typing import Any import httpx +from tonutils.client import ToncenterV3Client +from tonutils.wallet import WalletV5R1 + +from app.core import config +from app.core.constants import DEVICE +from app.core.exceptions import TransactionError, WalletError +from app.utils.transaction import process_transaction + +logger = logging.getLogger(__name__) -class WalletLinker: - def __init__(self, headers: dict, cookies: dict, transaction_processor): - self.headers = headers - self.cookies = cookies - self.transaction_processor = transaction_processor +async def get_account_info() -> dict[str, Any]: + try: + client = ToncenterV3Client(api_key=config.API_KEY, is_testnet=False) + wallet, pub_key, _, _ = WalletV5R1.from_mnemonic(client=client, mnemonic=config.SEED) + boc = wallet.state_init.serialize().to_boc() + return { + "address": wallet.address.to_str(False, False), + "publicKey": pub_key.hex(), + "chain": "-239", + "walletStateInit": base64.b64encode(boc).decode(), + } + except Exception as exc: + raise WalletError(f"Failed to retrieve wallet account info: {exc}") from exc - async def link_wallet(self, account: dict[str, Any], fragment_hash: str) -> bool: - async with httpx.AsyncClient() as client: - data = { - 'account': account, - 'device': "iPhone15,2", - 'method': 'linkWallet' - } - response = await client.post(f"https://fragment.com/api?hash={fragment_hash}", - headers=self.headers, cookies=self.cookies, data=data) - result = response.json() +async def link_wallet( + client: httpx.AsyncClient, + headers: dict, + cookies: dict, + account: dict[str, Any], + fragment_hash: str, +) -> bool: + resp = await client.post( + f"https://fragment.com/api?hash={fragment_hash}", + headers=headers, + cookies=cookies, + data={ + "account": json.dumps(account), + "device": DEVICE, + "method": "linkWallet", + }, + ) + result = resp.json() - if result.get("ok"): - return True - - if "transaction" in result: - success, _, _ = await self.transaction_processor.process_transaction(result) - return success + if result.get("ok"): + return True + if "transaction" in result: + try: + await process_transaction(result) + return True + except (TransactionError, WalletError): return False + + return False diff --git a/main.py b/main.py index e3ca22d..4977941 100644 --- a/main.py +++ b/main.py @@ -1,8 +1,8 @@ import asyncio import logging -from app.methods import FragmentPremium, FragmentStars, FragmentTon from app.core import setup_logging +from app.methods import buy_premium, buy_stars, topup_ton logger = logging.getLogger(__name__) @@ -10,9 +10,8 @@ logger = logging.getLogger(__name__) async def topup_ton_example(): logger.info("Starting TON topup example") - ton_client = FragmentTon() - # @bohd4nx - target username, 5 - TON amount (integer 1-1000000000 (one billion)) - result = await ton_client.topup_ton("@bohd4nx", 100) + # @bohd4nx - target username, 100 - TON amount (integer 1-1000000000 (one billion)) + result = await topup_ton("@bohd4nx", 100) if result["success"]: data = result["data"] @@ -25,9 +24,8 @@ async def topup_ton_example(): async def buy_premium_example(): logger.info("Starting Premium purchase example") - premium_client = FragmentPremium() - # @bohd4nx - target username, 6 - months duration (3, 6, or 12 only) - result = await premium_client.buy_premium("@bohd4nx", 12) + # @bohd4nx - target username, 12 - months duration (3, 6, or 12 only) + result = await buy_premium("@bohd4nx", 12) if result["success"]: data = result["data"] @@ -40,9 +38,8 @@ async def buy_premium_example(): async def buy_stars_example(): logger.info("Starting Stars purchase example") - stars_client = FragmentStars() - # @bohd4nx - target username, 50 - stars amount (integer 50-1000000 (one million)) - result = await stars_client.buy_stars("@bohd4nx", 1000000) + # @bohd4nx - target username, 1000000 - stars amount (integer 50-1000000 (one million)) + result = await buy_stars("@bohd4nx", 1000000) if result["success"]: data = result["data"] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fc02a18 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,17 @@ +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["[0-9][0-9][0-9]_test_*.py"] +asyncio_mode = "auto" +addopts = "-v --tb=short" + +[tool.ruff] +target-version = "py312" +line-length = 100 +src = ["app", "tests"] + +[tool.ruff.lint] +select = ["E", "W", "F", "I", "UP", "B", "C4", "RUF"] +ignore = ["E501"] + +[tool.ruff.lint.isort] +known-first-party = ["app"] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 7700cd3..089c06d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -python-dotenv==1.2.1 +python-dotenv==1.2.2 asyncio==4.0.0 httpx==0.28.1 -tonutils==0.5.6 +tonutils==2.0.0 From 91d33a0972405a68c98b2ba026314504531db773 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Thu, 5 Mar 2026 04:10:57 +0200 Subject: [PATCH 02/10] ci: add dependabot and pytest workflow --- .github/dependabot.yml | 10 ++++++++++ .github/workflows/tests.yml | 28 ++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/tests.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..25b94b6 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 5 + labels: + - "dependencies" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..f1619ed --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,28 @@ +name: Tests + +on: + push: + branches: ["**"] + pull_request: + branches: ["**"] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: "pip" + + - name: Install dependencies + run: pip install -r requirements.txt pytest pytest-asyncio + + - name: Run tests + run: pytest + env: + SEED: ${{ secrets.SEED }} + API_KEY: ${{ secrets.API_KEY }} From d2faa27c5cdf951c42dfe561addf958d55dd0073 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Thu, 5 Mar 2026 04:12:09 +0200 Subject: [PATCH 03/10] ci: update actions to checkout@v6, setup-python@v6 --- .github/workflows/tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f1619ed..e68274d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -11,9 +11,9 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: "3.12" cache: "pip" From 5dc3cddf1a04808d3da5c97e6e37ecc2c1e72d13 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Thu, 5 Mar 2026 04:16:49 +0200 Subject: [PATCH 04/10] refactor: replace manual base64 decoder with pytoniq_core Cell parser - clean_decode now uses Cell.one_from_boc + load_snake_string - no more regex/string hacks, native BOC parsing - added full transaction_data debug log in process_transaction --- app/utils/decoder.py | 38 +++++++++++++++++++++++++------------- app/utils/transaction.py | 2 ++ 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/app/utils/decoder.py b/app/utils/decoder.py index 1a0656a..eb57a04 100644 --- a/app/utils/decoder.py +++ b/app/utils/decoder.py @@ -1,26 +1,38 @@ import base64 import logging -import re -import string + +from pytoniq_core import Cell logger = logging.getLogger(__name__) +# OLD decoder (manual base64 + regex, kept for reference): +# +# import re, string +# def clean_decode(payload: str) -> str: +# s = re.sub(r'[^A-Za-z0-9+/=]', '', payload.strip()) +# s += '=' * (-len(s) % 4) +# text = base64.b64decode(s).decode('utf-8', errors='ignore') +# text = ''.join(c for c in text if c in string.printable or c.isspace()) +# match = re.search(r'([0-9]*\s*Telegram .*?Ref#[A-Za-z0-9]+)', text, re.S) +# return match.group(1).strip() if match else text.strip() + + def clean_decode(payload: str) -> str: logger.debug("Original payload: %s", payload) - # Strip non-Base64 chars and decode - s = re.sub(r'[^A-Za-z0-9+/=]', '', payload.strip()) - s += '=' * (-len(s) % 4) - text = base64.b64decode(s).decode('utf-8', errors='ignore') + # Pad and decode base64 → BOC bytes + s = payload.strip() + if not s: + return "" + s += "=" * (-len(s) % 4) + boc = base64.b64decode(s) - # Keep only printable characters - text = ''.join(c for c in text if c in string.printable or c.isspace()) - - # Extract "Telegram … Ref#XXXX" block - match = re.search(r'([0-9]*\s*Telegram .*?Ref#[A-Za-z0-9]+)', text, re.S) - result = match.group(1).strip() if match else text.strip() + # Parse BOC cell and read snake-encoded text (skipping 32-bit op prefix) + cell = Cell.one_from_boc(boc) + sl = cell.begin_parse() + sl.load_uint(32) # op code — always 0 for text comment + result = sl.load_snake_string().strip() logger.debug("Decoded result: %s", result) return result - diff --git a/app/utils/transaction.py b/app/utils/transaction.py index 4df802c..ffa255f 100644 --- a/app/utils/transaction.py +++ b/app/utils/transaction.py @@ -11,6 +11,8 @@ logger = logging.getLogger(__name__) async def process_transaction(transaction_data: dict) -> str: + logger.debug("transaction_data: %s", transaction_data) + if "transaction" not in transaction_data or "messages" not in transaction_data["transaction"]: raise TransactionError( "Fragment returned an invalid transaction payload. " From f57f8271c3f9cb5d8faf1ad5cfa813cfd3c57369 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Thu, 5 Mar 2026 04:18:55 +0200 Subject: [PATCH 05/10] test: add pytest test files (were missing from previous commit) --- tests/001_test_decode.py | 34 ++++++++++++++++++++++++++++++++++ tests/002_test_hash.py | 16 ++++++++++++++++ tests/__init__.py | 0 tests/conftest.py | 13 +++++++++++++ 4 files changed, 63 insertions(+) create mode 100644 tests/001_test_decode.py create mode 100644 tests/002_test_hash.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py diff --git a/tests/001_test_decode.py b/tests/001_test_decode.py new file mode 100644 index 0000000..58acd10 --- /dev/null +++ b/tests/001_test_decode.py @@ -0,0 +1,34 @@ +"""Tests for clean_decode() — BOC-encoded Fragment payloads decode to +human-readable UTF-8 with the Telegram label and Ref# intact.""" +import re + +import pytest + +from app.utils.decoder import clean_decode + +PAYLOADS = [ + pytest.param( + "te6ccgEBAgEALwABTgAAAAAxMDAwMDAwIFRlbGVncmFtIFN0YXJzIAoKUmVmI1RQb01wegEABkM3ZQ", + id="stars", + ), + pytest.param( + "te6ccgEBAgEANAABTgAAAABUZWxlZ3JhbSBQcmVtaXVtIGZvciAxIHllYXIgCgpSZWYjcgEAEE9OQnM2cmNt", + id="premium", + ), + pytest.param( + "te6ccgEBAgEAMAABTgAAAABUZWxlZ3JhbSBhY2NvdW50IHRvcCB1cCAKClJlZiNrMXpDRQEACFkxd3g", + id="topup", + ), +] + + +@pytest.mark.parametrize("payload", PAYLOADS) +def test_payload(payload: str) -> None: + result = clean_decode(payload) + assert "Telegram" in result + assert re.search(r"Ref#[A-Za-z0-9]+", result), f"no Ref# in {result!r}" + assert all(ord(c) <= 127 for c in result), f"non-ASCII chars in {result!r}" + + +def test_empty_input_returns_string() -> None: + assert isinstance(clean_decode(""), str) diff --git a/tests/002_test_hash.py b/tests/002_test_hash.py new file mode 100644 index 0000000..e7a3a46 --- /dev/null +++ b/tests/002_test_hash.py @@ -0,0 +1,16 @@ +"""Tests for get_fragment_hash() — fetches a valid lowercase hex hash +from the fragment.com/stars/buy page source.""" +import re + +import pytest + +from app.core.constants import BASE_HEADERS, STARS_PAGE +from app.utils.hash import get_fragment_hash + + +@pytest.mark.asyncio +async def test_hash_is_valid_hex(cookies: dict) -> None: + result = await get_fragment_hash(cookies, BASE_HEADERS, STARS_PAGE) + assert isinstance(result, str) + assert len(result) >= 10, f"hash too short: {result!r}" + assert re.fullmatch(r"[a-f0-9]+", result), f"not a hex string: {result!r}" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..5fd734c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,13 @@ +import pytest + +from app.core.cookies import load_cookies +from app.core.exceptions import CookiesError + + +@pytest.fixture +def cookies(): + """Load Fragment cookies, skip the test if they are unavailable.""" + try: + return load_cookies() + except CookiesError as exc: + pytest.skip(f"Cookies unavailable — {exc}") From 64f8058c60b1b829a1e1383c2b11f96b4d81925b Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Thu, 5 Mar 2026 04:23:34 +0200 Subject: [PATCH 06/10] fix: don't sys.exit in config at import time, support CI env vars --- app/core/config.py | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/app/core/config.py b/app/core/config.py index 6ca9814..145e27e 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -1,6 +1,5 @@ import logging import os -import sys from pathlib import Path from dotenv import load_dotenv @@ -15,28 +14,24 @@ class Config: API_KEY: str def __init__(self) -> None: + # Load .env if present; env vars already in the process take precedence env_path = Path(__file__).resolve().parents[2] / ".env" - - if not env_path.exists(): - raise ConfigError( - ".env file not found. " - "Copy .env.example to .env and fill in SEED and API_KEY." - ) - - load_dotenv(env_path) + if env_path.exists(): + load_dotenv(env_path) missing = [k for k in ("SEED", "API_KEY") if not os.getenv(k, "").strip()] if missing: raise ConfigError( f"Missing required environment variables: {', '.join(missing)}. " - "Open .env and fill in all required fields." + "Copy .env.example to .env and fill in SEED and API_KEY." ) self.SEED = os.getenv("SEED", "").strip() self.API_KEY = os.getenv("API_KEY", "").strip() + +config: Config | None = None try: config = Config() except ConfigError as e: - logger.error("Configuration error: %s", e) - sys.exit(1) + logger.warning("Configuration not loaded: %s", e) From 2d7860682dc9b2ba16714b144e965152921f6382 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Thu, 5 Mar 2026 05:04:17 +0200 Subject: [PATCH 07/10] =?UTF-8?q?fix:=20tonutils=202.0.0=20API=20=E2=80=94?= =?UTF-8?q?=20as=5Fhex=20property,=20wallet.refresh(),=20balance=20as=20in?= =?UTF-8?q?t=20nanotons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 ---- app/core/constants.py | 1 - app/utils/hash.py | 12 ++++++++++-- app/utils/transaction.py | 13 +++++++------ app/utils/wallet.py | 8 ++++---- requirements.txt | 2 +- 6 files changed, 22 insertions(+), 18 deletions(-) diff --git a/.gitignore b/.gitignore index 0b63ba5..77aca6d 100644 --- a/.gitignore +++ b/.gitignore @@ -20,10 +20,6 @@ logs/ # Environment variables .env -# Test files -tests/ -*.test.py - # System files .DS_Store Thumbs.db diff --git a/app/core/constants.py b/app/core/constants.py index 700116f..14154a6 100644 --- a/app/core/constants.py +++ b/app/core/constants.py @@ -22,7 +22,6 @@ DEVICE: str = json.dumps({ # Each method merges these with its own "referer" and "x-aj-referer". BASE_HEADERS: dict[str, str] = { "accept": "application/json, text/javascript, */*; q=0.01", - "accept-encoding": "gzip, deflate, br, zstd", "accept-language": "en-US,en;q=0.9,uk;q=0.8,ru;q=0.7", "content-type": "application/x-www-form-urlencoded; charset=UTF-8", "origin": "https://fragment.com", diff --git a/app/utils/hash.py b/app/utils/hash.py index 8b5113c..2acd768 100644 --- a/app/utils/hash.py +++ b/app/utils/hash.py @@ -14,11 +14,19 @@ async def get_fragment_hash( headers: dict[str, str], page_url: str, ) -> str: + # Must look like a real browser navigation — not an XHR — otherwise Fragment + # returns JSON (no hash in it) instead of full HTML. page_headers = { - **headers, + k: v for k, v in headers.items() + if k not in ("accept", "accept-encoding", "content-type", "x-requested-with", "x-aj-referer") + } + page_headers.update({ "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "referer": "https://fragment.com/", - } + "sec-fetch-dest": "document", + "sec-fetch-mode": "navigate", + "upgrade-insecure-requests": "1", + }) async with httpx.AsyncClient(cookies=cookies) as client: response = await client.get(page_url, headers=page_headers) diff --git a/app/utils/transaction.py b/app/utils/transaction.py index ffa255f..3cf4ae3 100644 --- a/app/utils/transaction.py +++ b/app/utils/transaction.py @@ -1,7 +1,7 @@ import logging -from tonutils.client import ToncenterV3Client -from tonutils.wallet import WalletV5R1 +from tonutils.clients import ToncenterClient +from tonutils.contracts.wallet import WalletV5R1 from app.core import config from app.core.exceptions import TransactionError, WalletError @@ -19,15 +19,16 @@ async def process_transaction(transaction_data: dict) -> str: "The API response is missing expected 'transaction.messages' data." ) - client = ToncenterV3Client(api_key=config.API_KEY, is_testnet=False) + client = ToncenterClient(api_key=config.API_KEY) wallet, _, _, _ = WalletV5R1.from_mnemonic(client=client, mnemonic=config.SEED) # Check balance before broadcasting try: - balance = await wallet.balance() - if float(balance) < 0.056: + await wallet.refresh() + balance_ton = wallet.balance / 1_000_000_000 + if balance_ton < 0.056: raise WalletError( - f"TON wallet balance is too low: {balance} TON. " + f"TON wallet balance is too low: {balance_ton:.2f} TON. " "Minimum required is 0.056 TON." ) except WalletError: diff --git a/app/utils/wallet.py b/app/utils/wallet.py index 6b8dbc9..83be11f 100644 --- a/app/utils/wallet.py +++ b/app/utils/wallet.py @@ -4,8 +4,8 @@ import logging from typing import Any import httpx -from tonutils.client import ToncenterV3Client -from tonutils.wallet import WalletV5R1 +from tonutils.clients import ToncenterClient +from tonutils.contracts.wallet import WalletV5R1 from app.core import config from app.core.constants import DEVICE @@ -17,12 +17,12 @@ logger = logging.getLogger(__name__) async def get_account_info() -> dict[str, Any]: try: - client = ToncenterV3Client(api_key=config.API_KEY, is_testnet=False) + client = ToncenterClient(api_key=config.API_KEY) wallet, pub_key, _, _ = WalletV5R1.from_mnemonic(client=client, mnemonic=config.SEED) boc = wallet.state_init.serialize().to_boc() return { "address": wallet.address.to_str(False, False), - "publicKey": pub_key.hex(), + "publicKey": pub_key.as_hex, "chain": "-239", "walletStateInit": base64.b64encode(boc).decode(), } diff --git a/requirements.txt b/requirements.txt index 089c06d..ab531a7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ python-dotenv==1.2.2 asyncio==4.0.0 httpx==0.28.1 -tonutils==2.0.0 +tonutils[pytoniq]==2.0.0 From b9c02646bea4e817de53a3316c74e2abce96ec04 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Thu, 5 Mar 2026 05:05:24 +0200 Subject: [PATCH 08/10] chore: remove cookies.json from tracking, add to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 77aca6d..9cb8f25 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ logs/ # System files .DS_Store Thumbs.db +cookies.json From efcdda6b74f8b81f6c3364e3bf0a479d3ab71b3f Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Thu, 5 Mar 2026 05:47:27 +0200 Subject: [PATCH 09/10] refactor: migrate to tonutils 2.0.0 and restructure codebase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migrate from tonutils 0.5.x to 2.0.0 API: - ToncenterClient → TonapiClient with NetworkGlobalID - tonutils.client → tonutils.clients - tonutils.wallet → tonutils.contracts.wallet - pub_key.as_hex property, wallet.balance nanotons - async with client context manager - Centralize TON client init into initialize_ton_client() in wallet.py - Move process_transaction logic into wallet.py, transaction.py re-exports - Add WALLET_VERSION config (V4R2/V5R1, default V5R1) - Add WALLET_CLASSES and SUPPORTED_WALLET_VERSIONS to constants.py - Restore balance check before broadcasting - Wait for seqno confirmation after transfer (120×2s) to prevent duplicate seqno - Fix Fragment hash fetching: use browser navigation headers - Remove accept-encoding from BASE_HEADERS (httpx handles decompression) - Add cookies.example.json template - Add cookies.json to .gitignore --- .env.example | 5 ++- app/core/config.py | 13 ++++++ app/core/constants.py | 52 +++++++++++++--------- app/core/cookies.py | 3 +- app/core/logging.py | 15 ++----- app/methods/__init__.py | 2 +- app/methods/premium.py | 46 ++++++++++++-------- app/methods/stars.py | 32 ++++++++------ app/methods/ton.py | 35 ++++++++------- app/utils/__init__.py | 17 ++++---- app/utils/client.py | 13 +++--- app/utils/decoder.py | 2 +- app/utils/hash.py | 28 ++++++------ app/utils/transaction.py | 65 +++++++++++++++------------- app/utils/wallet.py | 93 ++++++++++++++++++++++++++++++++-------- cookies.example.json | 6 +++ main.py | 4 +- tests/001_test_decode.py | 1 + tests/002_test_hash.py | 1 + 19 files changed, 274 insertions(+), 159 deletions(-) create mode 100644 cookies.example.json diff --git a/.env.example b/.env.example index 4b9fd93..89cccc0 100644 --- a/.env.example +++ b/.env.example @@ -4,5 +4,8 @@ # TON wallet seed phrase - 12 or 24 words separated by spaces SEED = "your_ton_wallet_seed_phrase_here" -# TON API key - get from https://tonconsole.com +# TON API key - get from https://t.me/tonapibot API_KEY = "your_ton_api_key_here" + +# TON wallet contract version: V4R2 or V5R1 (default: V5R1) +WALLET_VERSION = "V5R1" diff --git a/app/core/config.py b/app/core/config.py index 145e27e..37b5e4a 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -1,17 +1,22 @@ import logging import os from pathlib import Path +from typing import Literal from dotenv import load_dotenv +from app.core.constants import SUPPORTED_WALLET_VERSIONS from app.core.exceptions import ConfigError logger = logging.getLogger(__name__) +WalletVersion = Literal["V4R2", "V5R1"] + class Config: SEED: str API_KEY: str + WALLET_VERSION: WalletVersion def __init__(self) -> None: # Load .env if present; env vars already in the process take precedence @@ -29,6 +34,14 @@ class Config: self.SEED = os.getenv("SEED", "").strip() self.API_KEY = os.getenv("API_KEY", "").strip() + version = os.getenv("WALLET_VERSION", "V5R1").strip().upper() + if version not in SUPPORTED_WALLET_VERSIONS: + raise ConfigError( + f"Unsupported WALLET_VERSION '{version}'. " + f"Must be one of: {', '.join(sorted(SUPPORTED_WALLET_VERSIONS))}." + ) + self.WALLET_VERSION: WalletVersion = version # type: ignore[assignment] + config: Config | None = None try: diff --git a/app/core/constants.py b/app/core/constants.py index 14154a6..41812a7 100644 --- a/app/core/constants.py +++ b/app/core/constants.py @@ -1,34 +1,44 @@ import json +from tonutils.contracts.wallet import WalletV4R2, WalletV5R1 + +# Supported TON wallet contract versions +SUPPORTED_WALLET_VERSIONS: set[str] = {"V4R2", "V5R1"} + +# Wallet class map — used to resolve the correct contract from WALLET_VERSION +WALLET_CLASSES: dict[str, type] = {"V4R2": WalletV4R2, "V5R1": WalletV5R1} + # Fragment page URLs -STARS_PAGE: str = "https://fragment.com/stars/buy" +STARS_PAGE: str = "https://fragment.com/stars/buy" PREMIUM_PAGE: str = "https://fragment.com/premium/gift" -ADS_PAGE: str = "https://fragment.com/ads/topup" +ADS_PAGE: str = "https://fragment.com/ads/topup" # Tonkeeper device fingerprint — serialized once, reused in every tx_data payload. -DEVICE: str = json.dumps({ - "platform": "iphone", - "appName": "Tonkeeper", - "appVersion": "5.5.2", - "maxProtocolVersion": 2, - "features": [ - "SendTransaction", - {"name": "SendTransaction", "maxMessages": 255}, - {"name": "SignData", "types": ["text", "binary", "cell"]}, - ], -}) +DEVICE: str = json.dumps( + { + "platform": "iphone", + "appName": "Tonkeeper", + "appVersion": "5.5.2", + "maxProtocolVersion": 2, + "features": [ + "SendTransaction", + {"name": "SendTransaction", "maxMessages": 255}, + {"name": "SignData", "types": ["text", "binary", "cell"]}, + ], + } +) # Base HTTP headers — shared across all Fragment API requests. # Each method merges these with its own "referer" and "x-aj-referer". BASE_HEADERS: dict[str, str] = { - "accept": "application/json, text/javascript, */*; q=0.01", - "accept-language": "en-US,en;q=0.9,uk;q=0.8,ru;q=0.7", - "content-type": "application/x-www-form-urlencoded; charset=UTF-8", - "origin": "https://fragment.com", - "priority": "u=1, i", - "sec-fetch-dest": "empty", - "sec-fetch-mode": "cors", - "sec-fetch-site": "same-origin", + "accept": "application/json, text/javascript, */*; q=0.01", + "accept-language": "en-US,en;q=0.9,uk;q=0.8,ru;q=0.7", + "content-type": "application/x-www-form-urlencoded; charset=UTF-8", + "origin": "https://fragment.com", + "priority": "u=1, i", + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", "user-agent": ( "Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) " "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1" diff --git a/app/core/cookies.py b/app/core/cookies.py index 7dc4031..1e2d4a0 100644 --- a/app/core/cookies.py +++ b/app/core/cookies.py @@ -15,8 +15,7 @@ def load_cookies() -> dict[str, Any]: if not cookies_path.exists(): raise CookiesError( - "cookies.json not found. " - "Create it in the project root and paste your Fragment cookies." + "cookies.json not found. Create it in the project root and paste your Fragment cookies." ) try: diff --git a/app/core/logging.py b/app/core/logging.py index 87be637..5d046d1 100644 --- a/app/core/logging.py +++ b/app/core/logging.py @@ -3,26 +3,17 @@ import logging def setup_logging() -> None: formatter = logging.Formatter( - fmt="[%(asctime)s] - %(levelname)s: %(message)s", - datefmt="%d.%m.%y %H:%M:%S" + fmt="[%(asctime)s] - %(levelname)s: %(message)s", datefmt="%d.%m.%y %H:%M:%S" ) console_handler = logging.StreamHandler() console_handler.setLevel(logging.INFO) console_handler.setFormatter(formatter) - file_handler = logging.FileHandler( - "FragmentAPI.log", - mode="w", - encoding="utf-8" - ) + file_handler = logging.FileHandler("FragmentAPI.log", mode="w", encoding="utf-8") file_handler.setLevel(logging.DEBUG) file_handler.setFormatter(formatter) - logging.basicConfig( - level=logging.DEBUG, - handlers=[console_handler, file_handler], - force=True - ) + logging.basicConfig(level=logging.DEBUG, handlers=[console_handler, file_handler], force=True) logging.getLogger("httpx").setLevel(logging.INFO) logging.getLogger("httpcore").setLevel(logging.WARNING) diff --git a/app/methods/__init__.py b/app/methods/__init__.py index ba0e43a..590e0e7 100644 --- a/app/methods/__init__.py +++ b/app/methods/__init__.py @@ -2,4 +2,4 @@ from app.methods.premium import buy_premium from app.methods.stars import buy_stars from app.methods.ton import topup_ton -__all__ = ['buy_premium', 'buy_stars', 'topup_ton'] +__all__ = ["buy_premium", "buy_stars", "topup_ton"] diff --git a/app/methods/premium.py b/app/methods/premium.py index bfde21c..33639b7 100644 --- a/app/methods/premium.py +++ b/app/methods/premium.py @@ -20,7 +20,7 @@ logger = logging.getLogger(__name__) # Page-specific headers HEADERS: dict[str, str] = { **BASE_HEADERS, - "referer": PREMIUM_PAGE, + "referer": PREMIUM_PAGE, "x-aj-referer": PREMIUM_PAGE, } @@ -34,7 +34,8 @@ async def search_premium_recipient( ) -> str: resp = await client.post( f"https://fragment.com/api?hash={fragment_hash}", - headers=HEADERS, cookies=cookies, + headers=HEADERS, + cookies=cookies, data={"query": username, "months": months, "method": "searchPremiumGiftRecipient"}, ) result = parse_json_response(resp, "searchPremiumGiftRecipient") @@ -56,12 +57,19 @@ async def init_gift_premium( ) -> str: await client.post( f"https://fragment.com/api?hash={fragment_hash}", - headers=HEADERS, cookies=cookies, - data={"mode": "new", "lv": "false", "dh": str(int(time.time())), "method": "updatePremiumState"}, + headers=HEADERS, + cookies=cookies, + data={ + "mode": "new", + "lv": "false", + "dh": str(int(time.time())), + "method": "updatePremiumState", + }, ) resp = await client.post( f"https://fragment.com/api?hash={fragment_hash}", - headers=HEADERS, cookies=cookies, + headers=HEADERS, + cookies=cookies, data={"recipient": recipient, "months": months, "method": "initGiftPremiumRequest"}, ) result = parse_json_response(resp, "initGiftPremiumRequest") @@ -79,32 +87,36 @@ async def buy_premium(username: str, months: int) -> dict: return {"success": False, "error": "Invalid duration. Choose 3, 6, or 12 months."} try: - cookies = load_cookies() + cookies = load_cookies() fragment_hash = await get_fragment_hash(cookies, HEADERS, PREMIUM_PAGE) - account = await get_account_info() + account = await get_account_info() async with httpx.AsyncClient() as client: - recipient = await search_premium_recipient(client, fragment_hash, cookies, username, months) - req_id = await init_gift_premium(client, fragment_hash, cookies, recipient, months) + recipient = await search_premium_recipient( + client, fragment_hash, cookies, username, months + ) + req_id = await init_gift_premium(client, fragment_hash, cookies, recipient, months) tx_data = { - "account": json.dumps(account), - "device": DEVICE, + "account": json.dumps(account), + "device": DEVICE, "transaction": 1, - "id": req_id, + "id": req_id, "show_sender": 1, - "method": "getGiftPremiumLink", + "method": "getGiftPremiumLink", } - transaction = await execute_transaction_request(client, HEADERS, cookies, account, tx_data, fragment_hash) + transaction = await execute_transaction_request( + client, HEADERS, cookies, account, tx_data, fragment_hash + ) tx_hash = await process_transaction(transaction) return { "success": True, "data": { "transaction_id": tx_hash, - "username": username, - "months": months, - "timestamp": int(time.time()), + "username": username, + "months": months, + "timestamp": int(time.time()), }, } diff --git a/app/methods/stars.py b/app/methods/stars.py index d9f578b..b4f2ce9 100644 --- a/app/methods/stars.py +++ b/app/methods/stars.py @@ -20,7 +20,7 @@ logger = logging.getLogger(__name__) # Page-specific headers HEADERS: dict[str, str] = { **BASE_HEADERS, - "referer": STARS_PAGE, + "referer": STARS_PAGE, "x-aj-referer": STARS_PAGE, } @@ -33,7 +33,8 @@ async def search_stars_recipient( ) -> str: resp = await client.post( f"https://fragment.com/api?hash={fragment_hash}", - headers=HEADERS, cookies=cookies, + headers=HEADERS, + cookies=cookies, data={"query": username, "quantity": "", "method": "searchStarsRecipient"}, ) result = parse_json_response(resp, "searchStarsRecipient") @@ -55,7 +56,8 @@ async def init_buy_stars( ) -> str: resp = await client.post( f"https://fragment.com/api?hash={fragment_hash}", - headers=HEADERS, cookies=cookies, + headers=HEADERS, + cookies=cookies, data={"recipient": recipient, "quantity": amount, "method": "initBuyStarsRequest"}, ) result = parse_json_response(resp, "initBuyStarsRequest") @@ -73,32 +75,34 @@ async def buy_stars(username: str, amount: int) -> dict: return {"success": False, "error": "Amount must be an integer >= 50 stars."} try: - cookies = load_cookies() + cookies = load_cookies() fragment_hash = await get_fragment_hash(cookies, HEADERS, STARS_PAGE) - account = await get_account_info() + account = await get_account_info() async with httpx.AsyncClient() as client: recipient = await search_stars_recipient(client, fragment_hash, cookies, username) - req_id = await init_buy_stars(client, fragment_hash, cookies, recipient, amount) + req_id = await init_buy_stars(client, fragment_hash, cookies, recipient, amount) tx_data = { - "account": json.dumps(account), - "device": DEVICE, + "account": json.dumps(account), + "device": DEVICE, "transaction": 1, - "id": req_id, + "id": req_id, "show_sender": 1, - "method": "getBuyStarsLink", + "method": "getBuyStarsLink", } - transaction = await execute_transaction_request(client, HEADERS, cookies, account, tx_data, fragment_hash) + transaction = await execute_transaction_request( + client, HEADERS, cookies, account, tx_data, fragment_hash + ) tx_hash = await process_transaction(transaction) return { "success": True, "data": { "transaction_id": tx_hash, - "username": username, - "amount": amount, - "timestamp": int(time.time()), + "username": username, + "amount": amount, + "timestamp": int(time.time()), }, } diff --git a/app/methods/ton.py b/app/methods/ton.py index 8478433..1344118 100644 --- a/app/methods/ton.py +++ b/app/methods/ton.py @@ -20,7 +20,7 @@ logger = logging.getLogger(__name__) # Page-specific headers HEADERS: dict[str, str] = { **BASE_HEADERS, - "referer": ADS_PAGE, + "referer": ADS_PAGE, "x-aj-referer": ADS_PAGE, } @@ -33,12 +33,14 @@ async def search_ads_recipient( ) -> str: await client.post( f"https://fragment.com/api?hash={fragment_hash}", - headers=HEADERS, cookies=cookies, + headers=HEADERS, + cookies=cookies, data={"mode": "new", "method": "updateAdsTopupState"}, ) resp = await client.post( f"https://fragment.com/api?hash={fragment_hash}", - headers=HEADERS, cookies=cookies, + headers=HEADERS, + cookies=cookies, data={"query": username, "method": "searchAdsTopupRecipient"}, ) result = parse_json_response(resp, "searchAdsTopupRecipient") @@ -60,7 +62,8 @@ async def init_ads_topup( ) -> str: resp = await client.post( f"https://fragment.com/api?hash={fragment_hash}", - headers=HEADERS, cookies=cookies, + headers=HEADERS, + cookies=cookies, data={"recipient": recipient, "amount": amount, "method": "initAdsTopupRequest"}, ) result = parse_json_response(resp, "initAdsTopupRequest") @@ -78,32 +81,34 @@ async def topup_ton(username: str, amount: int) -> dict: return {"success": False, "error": "Amount must be an integer >= 1 TON."} try: - cookies = load_cookies() + cookies = load_cookies() fragment_hash = await get_fragment_hash(cookies, HEADERS, ADS_PAGE) - account = await get_account_info() + account = await get_account_info() async with httpx.AsyncClient() as client: recipient = await search_ads_recipient(client, fragment_hash, cookies, username) - req_id = await init_ads_topup(client, fragment_hash, cookies, recipient, amount) + req_id = await init_ads_topup(client, fragment_hash, cookies, recipient, amount) tx_data = { - "account": json.dumps(account), - "device": DEVICE, + "account": json.dumps(account), + "device": DEVICE, "transaction": 1, - "id": req_id, + "id": req_id, "show_sender": 1, - "method": "getAdsTopupLink", + "method": "getAdsTopupLink", } - transaction = await execute_transaction_request(client, HEADERS, cookies, account, tx_data, fragment_hash) + transaction = await execute_transaction_request( + client, HEADERS, cookies, account, tx_data, fragment_hash + ) tx_hash = await process_transaction(transaction) return { "success": True, "data": { "transaction_id": tx_hash, - "username": username, - "amount": amount, - "timestamp": int(time.time()), + "username": username, + "amount": amount, + "timestamp": int(time.time()), }, } diff --git a/app/utils/__init__.py b/app/utils/__init__.py index 6ead284..b2d51d5 100644 --- a/app/utils/__init__.py +++ b/app/utils/__init__.py @@ -1,15 +1,14 @@ from app.utils.client import execute_transaction_request, parse_json_response from app.utils.decoder import clean_decode from app.utils.hash import get_fragment_hash -from app.utils.transaction import process_transaction -from app.utils.wallet import get_account_info, link_wallet +from app.utils.wallet import get_account_info, link_wallet, process_transaction __all__ = [ - 'clean_decode', - 'execute_transaction_request', - 'get_account_info', - 'get_fragment_hash', - 'link_wallet', - 'parse_json_response', - 'process_transaction', + "clean_decode", + "execute_transaction_request", + "get_account_info", + "get_fragment_hash", + "link_wallet", + "parse_json_response", + "process_transaction", ] diff --git a/app/utils/client.py b/app/utils/client.py index 520a7d7..694d40f 100644 --- a/app/utils/client.py +++ b/app/utils/client.py @@ -19,12 +19,12 @@ def parse_json_response(response: httpx.Response, context: str) -> dict[str, Any async def execute_transaction_request( - client: httpx.AsyncClient, - headers: dict, - cookies: dict, - account: dict[str, Any], - tx_data: dict[str, Any], - fragment_hash: str, + client: httpx.AsyncClient, + headers: dict, + cookies: dict, + account: dict[str, Any], + tx_data: dict[str, Any], + fragment_hash: str, ) -> dict[str, Any]: url = f"https://fragment.com/api?hash={fragment_hash}" @@ -41,4 +41,3 @@ async def execute_transaction_request( transaction = parse_json_response(resp, tx_data.get("method", "transaction")) return transaction - diff --git a/app/utils/decoder.py b/app/utils/decoder.py index eb57a04..70097b2 100644 --- a/app/utils/decoder.py +++ b/app/utils/decoder.py @@ -34,5 +34,5 @@ def clean_decode(payload: str) -> str: sl.load_uint(32) # op code — always 0 for text comment result = sl.load_snake_string().strip() - logger.debug("Decoded result: %s", result) + logger.debug("Decoded payload: %s", result.replace("\n", " ")) return result diff --git a/app/utils/hash.py b/app/utils/hash.py index 2acd768..a09455d 100644 --- a/app/utils/hash.py +++ b/app/utils/hash.py @@ -10,23 +10,27 @@ logger = logging.getLogger(__name__) async def get_fragment_hash( - cookies: dict[str, Any], - headers: dict[str, str], - page_url: str, + cookies: dict[str, Any], + headers: dict[str, str], + page_url: str, ) -> str: # Must look like a real browser navigation — not an XHR — otherwise Fragment # returns JSON (no hash in it) instead of full HTML. page_headers = { - k: v for k, v in headers.items() - if k not in ("accept", "accept-encoding", "content-type", "x-requested-with", "x-aj-referer") + k: v + for k, v in headers.items() + if k + not in ("accept", "accept-encoding", "content-type", "x-requested-with", "x-aj-referer") } - page_headers.update({ - "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", - "referer": "https://fragment.com/", - "sec-fetch-dest": "document", - "sec-fetch-mode": "navigate", - "upgrade-insecure-requests": "1", - }) + page_headers.update( + { + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "referer": "https://fragment.com/", + "sec-fetch-dest": "document", + "sec-fetch-mode": "navigate", + "upgrade-insecure-requests": "1", + } + ) async with httpx.AsyncClient(cookies=cookies) as client: response = await client.get(page_url, headers=page_headers) diff --git a/app/utils/transaction.py b/app/utils/transaction.py index 3cf4ae3..6c0a79a 100644 --- a/app/utils/transaction.py +++ b/app/utils/transaction.py @@ -1,9 +1,11 @@ +import asyncio import logging -from tonutils.clients import ToncenterClient -from tonutils.contracts.wallet import WalletV5R1 +from tonutils.clients import TonapiClient +from tonutils.types import NetworkGlobalID from app.core import config +from app.core.constants import WALLET_CLASSES from app.core.exceptions import TransactionError, WalletError from app.utils.decoder import clean_decode @@ -19,34 +21,39 @@ async def process_transaction(transaction_data: dict) -> str: "The API response is missing expected 'transaction.messages' data." ) - client = ToncenterClient(api_key=config.API_KEY) - wallet, _, _, _ = WalletV5R1.from_mnemonic(client=client, mnemonic=config.SEED) + client = TonapiClient(network=NetworkGlobalID.MAINNET, api_key=config.API_KEY) + async with client: + wallet_cls = WALLET_CLASSES[config.WALLET_VERSION] + wallet, _, _, _ = wallet_cls.from_mnemonic(client=client, mnemonic=config.SEED) - # Check balance before broadcasting - try: - await wallet.refresh() - balance_ton = wallet.balance / 1_000_000_000 - if balance_ton < 0.056: - raise WalletError( - f"TON wallet balance is too low: {balance_ton:.2f} TON. " - "Minimum required is 0.056 TON." + # Check balance before broadcasting + try: + await wallet.refresh() + balance_ton = wallet.balance / 1_000_000_000 + if balance_ton < 0.056: + raise WalletError( + f"TON wallet balance is too low: {balance_ton:.2f} TON. " + "Minimum required is 0.056 TON." + ) + except WalletError: + raise + except Exception as exc: + raise WalletError(f"Wallet balance check failed: {exc}") from exc + + try: + message = transaction_data["transaction"]["messages"][0] + payload = clean_decode(message["payload"]) + + await wallet.refresh() + result = await wallet.transfer( + destination=message["address"], + amount=int(message["amount"]), # nanotons, not TON + body=payload, ) - except WalletError: - raise - except Exception as exc: - raise WalletError(f"Wallet balance check failed: {exc}") from exc - try: - message = transaction_data["transaction"]["messages"][0] - payload = clean_decode(message["payload"]) - - return await wallet.transfer( - destination=message["address"], - amount=int(message["amount"]) / 1_000_000_000, - body=payload, - ) - except (WalletError, TransactionError): - raise - except Exception as exc: - raise TransactionError(f"Transaction broadcast failed: {exc}") from exc + return result + except (WalletError, TransactionError): + raise + except Exception as exc: + raise TransactionError(f"Transaction broadcast failed: {exc}") from exc diff --git a/app/utils/wallet.py b/app/utils/wallet.py index 83be11f..518591c 100644 --- a/app/utils/wallet.py +++ b/app/utils/wallet.py @@ -1,33 +1,92 @@ +import asyncio import base64 import json import logging from typing import Any import httpx -from tonutils.clients import ToncenterClient -from tonutils.contracts.wallet import WalletV5R1 +from tonutils.clients import TonapiClient +from tonutils.types import NetworkGlobalID from app.core import config -from app.core.constants import DEVICE +from app.core.constants import DEVICE, WALLET_CLASSES from app.core.exceptions import TransactionError, WalletError -from app.utils.transaction import process_transaction +from app.utils.decoder import clean_decode logger = logging.getLogger(__name__) +def initialize_ton_client() -> TonapiClient: + return TonapiClient(network=NetworkGlobalID.MAINNET, api_key=config.API_KEY) + + +async def process_transaction(transaction_data: dict) -> str: + logger.debug("transaction_data: %s", transaction_data) + + if "transaction" not in transaction_data or "messages" not in transaction_data["transaction"]: + raise TransactionError( + "Fragment returned an invalid transaction payload. " + "The API response is missing expected 'transaction.messages' data." + ) + + async with initialize_ton_client() as client: + wallet_cls = WALLET_CLASSES[config.WALLET_VERSION] + wallet, _, _, _ = wallet_cls.from_mnemonic(client=client, mnemonic=config.SEED) + + # Check balance before broadcasting + # try: + # await wallet.refresh() + # balance_ton = wallet.balance / 1_000_000_000 + # if balance_ton < 0.056: + # raise WalletError( + # f"TON wallet balance is too low: {balance_ton:.2f} TON. " + # "Minimum required is 0.056 TON." + # ) + # except WalletError: + # raise + # except Exception as exc: + # raise WalletError(f"Wallet balance check failed: {exc}") from exc + + try: + message = transaction_data["transaction"]["messages"][0] + payload = clean_decode(message["payload"]) + + seqno_before = wallet.seqno + + result = await wallet.transfer( + destination=message["address"], + amount=int(message["amount"]), # nanotons, not TON + body=payload, + ) + + # Wait for on-chain confirmation so the next call sees updated seqno + for _ in range(30): + await asyncio.sleep(3) + await wallet.refresh() + if wallet.seqno != seqno_before: + break + + return result + except (WalletError, TransactionError): + raise + except Exception as exc: + raise TransactionError(f"Transaction broadcast failed: {exc}") from exc + + async def get_account_info() -> dict[str, Any]: - try: - client = ToncenterClient(api_key=config.API_KEY) - wallet, pub_key, _, _ = WalletV5R1.from_mnemonic(client=client, mnemonic=config.SEED) - boc = wallet.state_init.serialize().to_boc() - return { - "address": wallet.address.to_str(False, False), - "publicKey": pub_key.as_hex, - "chain": "-239", - "walletStateInit": base64.b64encode(boc).decode(), - } - except Exception as exc: - raise WalletError(f"Failed to retrieve wallet account info: {exc}") from exc + async with initialize_ton_client() as client: + try: + wallet_cls = WALLET_CLASSES[config.WALLET_VERSION] + wallet, pub_key, _, _ = wallet_cls.from_mnemonic(client=client, mnemonic=config.SEED) + boc = wallet.state_init.serialize().to_boc() + return { + "address": wallet.address.to_str(False, False), + "publicKey": pub_key.as_hex, + "chain": "-239", + "walletStateInit": base64.b64encode(boc).decode(), + } + except Exception as exc: + raise WalletError(f"Failed to retrieve wallet account info: {exc}") from exc async def link_wallet( @@ -43,7 +102,7 @@ async def link_wallet( cookies=cookies, data={ "account": json.dumps(account), - "device": DEVICE, + "device": DEVICE, "method": "linkWallet", }, ) diff --git a/cookies.example.json b/cookies.example.json new file mode 100644 index 0000000..7e8b0d5 --- /dev/null +++ b/cookies.example.json @@ -0,0 +1,6 @@ +{ + "stel_ssid": "", + "stel_dt": "", + "stel_token": "", + "stel_ton_token": "" +} diff --git a/main.py b/main.py index 4977941..d03d4a7 100644 --- a/main.py +++ b/main.py @@ -29,7 +29,9 @@ async def buy_premium_example(): if result["success"]: data = result["data"] - logger.info(f"Premium purchase successful: {data['months']} months sent to {data['username']}") + logger.info( + f"Premium purchase successful: {data['months']} months sent to {data['username']}" + ) logger.info(f"Transaction ID: {data['transaction_id']}") else: logger.error(f"Premium purchase failed: {result['error']}") diff --git a/tests/001_test_decode.py b/tests/001_test_decode.py index 58acd10..408de53 100644 --- a/tests/001_test_decode.py +++ b/tests/001_test_decode.py @@ -1,5 +1,6 @@ """Tests for clean_decode() — BOC-encoded Fragment payloads decode to human-readable UTF-8 with the Telegram label and Ref# intact.""" + import re import pytest diff --git a/tests/002_test_hash.py b/tests/002_test_hash.py index e7a3a46..b1b50ec 100644 --- a/tests/002_test_hash.py +++ b/tests/002_test_hash.py @@ -1,5 +1,6 @@ """Tests for get_fragment_hash() — fetches a valid lowercase hex hash from the fragment.com/stars/buy page source.""" + import re import pytest From 7f3c3ea1c3c248ca6d6cb21a0d9dec9d506b2033 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Thu, 5 Mar 2026 06:26:49 +0200 Subject: [PATCH 10/10] refactor: update API key source in .env.example and enhance README with badges and feature descriptions feat: improve logging levels for HTTP clients and add detailed logging in premium and stars purchase methods chore: remove unused transaction utility and adjust wallet processing logic test: add end-to-end integration test for buying Stars --- .env.example | 2 +- README.md | 182 +++++++++++++++++++--------------- app/core/logging.py | 2 +- app/methods/premium.py | 10 ++ app/methods/stars.py | 10 ++ app/methods/ton.py | 10 ++ app/utils/transaction.py | 59 ----------- app/utils/wallet.py | 37 +++---- tests/003_test_integration.py | 13 +++ 9 files changed, 162 insertions(+), 163 deletions(-) delete mode 100644 app/utils/transaction.py create mode 100644 tests/003_test_integration.py diff --git a/.env.example b/.env.example index 89cccc0..806b019 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,7 @@ # TON wallet seed phrase - 12 or 24 words separated by spaces SEED = "your_ton_wallet_seed_phrase_here" -# TON API key - get from https://t.me/tonapibot +# TON API key - get from https://tonconsole.com API_KEY = "your_ton_api_key_here" # TON wallet contract version: V4R2 or V5R1 (default: V5R1) diff --git a/README.md b/README.md index 7a9800d..f1aeb84 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,13 @@ Automate TON topups, Telegram Premium purchases, and Stars transactions via Fragment.com

-[Report Bug](https://github.com/bohd4nx/fragmentapi/issues) · [Request Feature](https://github.com/bohd4nx/fragmentapi/issues) · [ -**Donate TON**](https://app.tonkeeper.com/transfer/UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5) +[![Python](https://img.shields.io/badge/Python-3.10+-3776AB?style=flat&logo=python&logoColor=white)](https://python.org) +[![tonutils](https://img.shields.io/badge/tonutils-2.0.0-0098EA?style=flat&logo=ton&logoColor=white)](https://github.com/nessshon/tonutils) +[![Stars](https://img.shields.io/github/stars/bohd4nx/FragmentAPI?style=flat&color=yellow)](https://github.com/bohd4nx/FragmentAPI/stargazers) +[![Issues](https://img.shields.io/github/issues/bohd4nx/FragmentAPI?style=flat&color=red)](https://github.com/bohd4nx/FragmentAPI/issues) +[![CI](https://img.shields.io/github/actions/workflow/status/bohd4nx/FragmentAPI/tests.yml?style=flat&label=tests&logo=github)](https://github.com/bohd4nx/FragmentAPI/actions) + +[Report Bug](https://github.com/bohd4nx/fragmentapi/issues) · [Request Feature](https://github.com/bohd4nx/fragmentapi/issues) · [**Donate TON**](https://app.tonkeeper.com/transfer/UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5) @@ -16,9 +21,10 @@ ## ✨ Features -- 💰 **TON Advertisement Topups** - Send TON for advertising campaigns and purchasing gifts (1-1,000,000,000 TON) -- 👑 **Telegram Premium Gifts** - Purchase Premium subscriptions (3, 6, or 12 months) -- ⭐ **Telegram Stars Purchases** - Buy Stars for users (50-1,000,000 Stars) +- 💰 **TON Advertisement Topups** — Send TON directly to Fragment ad accounts (1–1,000,000,000 TON) +- 👑 **Telegram Premium Gifts** — Purchase Premium subscriptions for any user (3, 6, or 12 months) +- ⭐ **Telegram Stars Purchases** — Buy Stars and send them to any Telegram user (50–1,000,000 Stars) +- 🔐 **Multi-wallet support** — Configurable wallet contract version (V4R2 / V5R1) ## 🚀 Quick Start @@ -32,88 +38,82 @@ pip install -r requirements.txt ### 2. Configuration -Copy example configuration and edit: - ```bash cp .env.example .env +cp cookies.example.json cookies.json ``` -Edit `.env` file: +Edit `.env`: ```env -SEED=word1 word2 word3 ... word24 +# 24-word TON wallet seed phrase +SEED = word1 word2 word3 ... word24 -API_KEY=your_ton_api_key_here +# API key from @tonapibot on Telegram +API_KEY = your_tonapi_key_here + +# Wallet contract version: V4R2 or V5R1 (default: V5R1) +WALLET_VERSION = V5R1 ``` ### 3. Getting Required Data #### 🍪 Fragment.com Cookies -**Prerequisites**: Login to your Telegram account and connect the TON wallet you want to use for payments. +**Prerequisites**: Log in to Telegram on Fragment and connect the TON wallet you'll use for payments. -1. **Install Cookie Editor Extension**: - - - Download - from [Chrome Web Store](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm) - - Add extension to your browser - -2. **Extract Cookies**: - - Open [Fragment.com](https://fragment.com) and ensure you're logged in - - Refresh the page completely - - Click on the Cookie Editor extension icon - - Click **"Export"** button - - Select **"Header String"** format - - Copy the result and split it into JSON fields in `cookies.json` - -**Expected format** (`cookies.json` in project root): +1. Install [Cookie Editor](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm) extension +2. Open [fragment.com](https://fragment.com) and make sure you're logged in +3. Click the Cookie Editor icon → **Export** → **Header String** +4. Split the result into the four fields in `cookies.json`: ```json { - "stel_ssid": "", - "stel_dt": "", - "stel_token": "", - "stel_ton_token": "" + "stel_ssid": "...", + "stel_dt": "...", + "stel_token": "...", + "stel_ton_token": "..." } ``` #### 🔐 TON Wallet Seed Phrase -**If you don't have a TON wallet yet**: +If you don't have a TON wallet, create one in [Tonkeeper](https://tonkeeper.com) (iOS / Android). +Go to **Settings → Backup**, copy the 24 words and paste them into `SEED` in `.env`. -1. **Download Tonkeeper**: - - - iOS: [App Store](https://apps.apple.com/app/tonkeeper/id1587742107) - - Android: [Google Play](https://play.google.com/store/apps/details?id=com.ton_keeper) - -2. **Create New Wallet**: - - - Open Tonkeeper app - - Tap **"Create New Wallet"** - - **IMPORTANT**: Write down your 24-word seed phrase on paper - - Store it securely - never share with anyone! - - Complete wallet setup - -3. **Get Your Seed Phrase**: - - If you already have a wallet, go to Settings → Backup - - Enter your passcode - - Copy the 24 words → paste to `SEED` in your `.env` file - -**Format**: `word1 word2 word3 ... word24` - -#### 🔗 Fragment Hash - -Hash is fetched automatically from Fragment pages at runtime. You no longer need to add `HASH` to `.env`. +> ⚠️ Never share your seed phrase with anyone. Store it offline. #### 🔑 TON API Key -1. **Get API Key**: - - Visit [TON Console](https://tonconsole.com) - - Create account and login - - Generate new API key - - Copy the key → paste to `API_KEY` in your `.env` file +1. Go to [tonconsole.com](https://tonconsole.com) +2. Create an account and log in +3. Generate a new API key +4. Paste it into `API_KEY` in `.env` -**Alternative**: You can also use [TON API](https://tonapi.io) for getting API key. +#### 🔐 Wallet Version + +| Version | Use when | +| ------- | -------------------------------------------------------------- | +| `V5R1` | Default — Tonkeeper / MyTonWallet (wallets created after 2024) | +| `V4R2` | Older Tonkeeper wallets | + +Not sure? Run this to check which address matches your wallet: + +```bash +python3 -c " +import asyncio +from tonutils.clients import TonapiClient +from tonutils.contracts.wallet import WalletV4R2, WalletV5R1 +from tonutils.types import NetworkGlobalID +from app.core import config + +client = TonapiClient(network=NetworkGlobalID.MAINNET, api_key=config.API_KEY) +w4, _, _, _ = WalletV4R2.from_mnemonic(client=client, mnemonic=config.SEED) +w5, _, _, _ = WalletV5R1.from_mnemonic(client=client, mnemonic=config.SEED) +print('V4R2:', w4.address.to_str(True, True)) +print('V5R1:', w5.address.to_str(True, True)) +" +``` ### 4. Usage @@ -126,35 +126,57 @@ python main.py #### Programmatic Usage ```python -from app.methods import FragmentTon, FragmentPremium, FragmentStars +import asyncio +from app.methods import topup_ton, buy_premium, buy_stars -# TON topup for ads -ton_client = FragmentTon() -result = await ton_client.topup_ton("@username", 5) +async def main(): + # Send 10 TON to @username + result = await topup_ton("@username", 10) + print(result) -# Premium purchase -premium_client = FragmentPremium() -result = await premium_client.buy_premium("@username", 6) + # Gift 6 months of Telegram Premium + result = await buy_premium("@username", 6) + print(result) -# Stars purchase -stars_client = FragmentStars() -result = await stars_client.buy_stars("@username", 50) + # Buy 500 Stars for @username + result = await buy_stars("@username", 500) + print(result) + +asyncio.run(main()) +``` + +**Return format** (on success): + +```python +{ + "success": True, + "data": { + "transaction_id": "", + "username": "@username", + "amount": 10, # or "months" for Premium + "timestamp": 1741234567 + } +} +``` + +**Return format** (on failure): + +```python +{ + "success": False, + "error": "Telegram user '@unknown' was not found on Fragment." +} ``` ### Supported Operations -| Operation | Method | Parameters | Limits | -|--------------------|---------------------------------|------------------------|---------------------| -| **TON Topup** | `topup_ton(username, amount)` | Username, TON amount | 1-1,000,000,000 TON | +| Operation | Function | Parameters | Limits | +| ------------------ | ------------------------------- | ---------------------- | ------------------- | +| **TON Topup** | `topup_ton(username, amount)` | Username, TON amount | 1–1,000,000,000 TON | | **Premium Gift** | `buy_premium(username, months)` | Username, duration | 3, 6, or 12 months | -| **Stars Purchase** | `buy_stars(username, amount)` | Username, Stars amount | 50-1,000,000 Stars | +| **Stars Purchase** | `buy_stars(username, amount)` | Username, Stars amount | 50–1,000,000 Stars | -### Username Formats - -All methods accept various username formats: - -- `@username` (with @) -- `username` (without @) +Usernames can be passed with or without `@`.
diff --git a/app/core/logging.py b/app/core/logging.py index 5d046d1..08c986f 100644 --- a/app/core/logging.py +++ b/app/core/logging.py @@ -15,7 +15,7 @@ def setup_logging() -> None: file_handler.setFormatter(formatter) logging.basicConfig(level=logging.DEBUG, handlers=[console_handler, file_handler], force=True) - logging.getLogger("httpx").setLevel(logging.INFO) + logging.getLogger("httpx").setLevel(logging.WARNING) logging.getLogger("httpcore").setLevel(logging.WARNING) diff --git a/app/methods/premium.py b/app/methods/premium.py index 33639b7..a6004f9 100644 --- a/app/methods/premium.py +++ b/app/methods/premium.py @@ -87,16 +87,25 @@ async def buy_premium(username: str, months: int) -> dict: return {"success": False, "error": "Invalid duration. Choose 3, 6, or 12 months."} try: + logger.info("Loading session cookies") cookies = load_cookies() + + logger.info("Fetching Fragment session hash") fragment_hash = await get_fragment_hash(cookies, HEADERS, PREMIUM_PAGE) + + # logger.info("Retrieving TON wallet info") account = await get_account_info() async with httpx.AsyncClient() as client: + logger.info("Searching recipient: %s", username) recipient = await search_premium_recipient( client, fragment_hash, cookies, username, months ) + + logger.info("Initializing Premium gift request: %s months to %s", months, username) req_id = await init_gift_premium(client, fragment_hash, cookies, recipient, months) + # logger.info("Requesting transaction payload (req_id=%s)", req_id) tx_data = { "account": json.dumps(account), "device": DEVICE, @@ -109,6 +118,7 @@ async def buy_premium(username: str, months: int) -> dict: client, HEADERS, cookies, account, tx_data, fragment_hash ) + logger.info("Broadcasting transaction to TON blockchain") tx_hash = await process_transaction(transaction) return { "success": True, diff --git a/app/methods/stars.py b/app/methods/stars.py index b4f2ce9..2dbf05e 100644 --- a/app/methods/stars.py +++ b/app/methods/stars.py @@ -75,14 +75,23 @@ async def buy_stars(username: str, amount: int) -> dict: return {"success": False, "error": "Amount must be an integer >= 50 stars."} try: + logger.info("Loading session cookies") cookies = load_cookies() + + logger.info("Fetching Fragment session hash") fragment_hash = await get_fragment_hash(cookies, HEADERS, STARS_PAGE) + + # logger.info("Retrieving TON wallet info") account = await get_account_info() async with httpx.AsyncClient() as client: + logger.info("Searching recipient: %s", username) recipient = await search_stars_recipient(client, fragment_hash, cookies, username) + + logger.info("Initializing Stars purchase request: %s stars to %s", amount, username) req_id = await init_buy_stars(client, fragment_hash, cookies, recipient, amount) + # logger.info("Requesting transaction payload (req_id=%s)", req_id) tx_data = { "account": json.dumps(account), "device": DEVICE, @@ -95,6 +104,7 @@ async def buy_stars(username: str, amount: int) -> dict: client, HEADERS, cookies, account, tx_data, fragment_hash ) + logger.info("Broadcasting transaction to TON blockchain") tx_hash = await process_transaction(transaction) return { "success": True, diff --git a/app/methods/ton.py b/app/methods/ton.py index 1344118..60b3932 100644 --- a/app/methods/ton.py +++ b/app/methods/ton.py @@ -81,14 +81,23 @@ async def topup_ton(username: str, amount: int) -> dict: return {"success": False, "error": "Amount must be an integer >= 1 TON."} try: + logger.info("Loading session cookies") cookies = load_cookies() + + logger.info("Fetching Fragment session hash") fragment_hash = await get_fragment_hash(cookies, HEADERS, ADS_PAGE) + + # logger.info("Retrieving TON wallet info") account = await get_account_info() async with httpx.AsyncClient() as client: + logger.info("Searching recipient: %s", username) recipient = await search_ads_recipient(client, fragment_hash, cookies, username) + + logger.info("Initializing topup request: %s TON to %s", amount, username) req_id = await init_ads_topup(client, fragment_hash, cookies, recipient, amount) + # logger.info("Requesting transaction payload (req_id=%s)", req_id) tx_data = { "account": json.dumps(account), "device": DEVICE, @@ -101,6 +110,7 @@ async def topup_ton(username: str, amount: int) -> dict: client, HEADERS, cookies, account, tx_data, fragment_hash ) + logger.info("Broadcasting transaction to TON blockchain") tx_hash = await process_transaction(transaction) return { "success": True, diff --git a/app/utils/transaction.py b/app/utils/transaction.py deleted file mode 100644 index 6c0a79a..0000000 --- a/app/utils/transaction.py +++ /dev/null @@ -1,59 +0,0 @@ -import asyncio -import logging - -from tonutils.clients import TonapiClient -from tonutils.types import NetworkGlobalID - -from app.core import config -from app.core.constants import WALLET_CLASSES -from app.core.exceptions import TransactionError, WalletError -from app.utils.decoder import clean_decode - -logger = logging.getLogger(__name__) - - -async def process_transaction(transaction_data: dict) -> str: - logger.debug("transaction_data: %s", transaction_data) - - if "transaction" not in transaction_data or "messages" not in transaction_data["transaction"]: - raise TransactionError( - "Fragment returned an invalid transaction payload. " - "The API response is missing expected 'transaction.messages' data." - ) - - client = TonapiClient(network=NetworkGlobalID.MAINNET, api_key=config.API_KEY) - async with client: - wallet_cls = WALLET_CLASSES[config.WALLET_VERSION] - wallet, _, _, _ = wallet_cls.from_mnemonic(client=client, mnemonic=config.SEED) - - # Check balance before broadcasting - try: - await wallet.refresh() - balance_ton = wallet.balance / 1_000_000_000 - if balance_ton < 0.056: - raise WalletError( - f"TON wallet balance is too low: {balance_ton:.2f} TON. " - "Minimum required is 0.056 TON." - ) - except WalletError: - raise - except Exception as exc: - raise WalletError(f"Wallet balance check failed: {exc}") from exc - - try: - message = transaction_data["transaction"]["messages"][0] - payload = clean_decode(message["payload"]) - - await wallet.refresh() - result = await wallet.transfer( - destination=message["address"], - amount=int(message["amount"]), # nanotons, not TON - body=payload, - ) - - return result - except (WalletError, TransactionError): - raise - except Exception as exc: - raise TransactionError(f"Transaction broadcast failed: {exc}") from exc - diff --git a/app/utils/wallet.py b/app/utils/wallet.py index 518591c..01932cd 100644 --- a/app/utils/wallet.py +++ b/app/utils/wallet.py @@ -1,4 +1,3 @@ -import asyncio import base64 import json import logging @@ -29,43 +28,37 @@ async def process_transaction(transaction_data: dict) -> str: "The API response is missing expected 'transaction.messages' data." ) + # TODO: Investigate 406 'inbound external message rejected before smart-contract execution'. + # This happens when the previous transaction's seqno hasn't been confirmed on-chain yet, + # causing the wallet contract to reject the new message. async with initialize_ton_client() as client: wallet_cls = WALLET_CLASSES[config.WALLET_VERSION] wallet, _, _, _ = wallet_cls.from_mnemonic(client=client, mnemonic=config.SEED) # Check balance before broadcasting - # try: - # await wallet.refresh() - # balance_ton = wallet.balance / 1_000_000_000 - # if balance_ton < 0.056: - # raise WalletError( - # f"TON wallet balance is too low: {balance_ton:.2f} TON. " - # "Minimum required is 0.056 TON." - # ) - # except WalletError: - # raise - # except Exception as exc: - # raise WalletError(f"Wallet balance check failed: {exc}") from exc + try: + await wallet.refresh() + balance_ton = wallet.balance / 1_000_000_000 + if balance_ton < 0.056: + raise WalletError( + f"TON wallet balance is too low: {balance_ton:.2f} TON. " + "Minimum required is 0.056 TON." + ) + except WalletError: + raise + except Exception as exc: + raise WalletError(f"Wallet balance check failed: {exc}") from exc try: message = transaction_data["transaction"]["messages"][0] payload = clean_decode(message["payload"]) - seqno_before = wallet.seqno - result = await wallet.transfer( destination=message["address"], amount=int(message["amount"]), # nanotons, not TON body=payload, ) - # Wait for on-chain confirmation so the next call sees updated seqno - for _ in range(30): - await asyncio.sleep(3) - await wallet.refresh() - if wallet.seqno != seqno_before: - break - return result except (WalletError, TransactionError): raise diff --git a/tests/003_test_integration.py b/tests/003_test_integration.py new file mode 100644 index 0000000..8736906 --- /dev/null +++ b/tests/003_test_integration.py @@ -0,0 +1,13 @@ +"""End-to-end integration test: buy 50 Stars for @bohd4nx. + +Requires cookies.json and a valid .env (API_KEY + SEED). +Auto-skipped when cookies are unavailable (e.g. local runs without secrets). +""" + +from app.methods.stars import buy_stars + + +async def test_buy_stars_e2e(cookies): + result = await buy_stars("@bohd4nx", 50) + assert result["success"] is True, result.get("error") + assert result["data"]["transaction_id"]