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)
+[](https://python.org)
+[](https://github.com/nessshon/tonutils)
+[](https://github.com/bohd4nx/FragmentAPI/stargazers)
+[](https://github.com/bohd4nx/FragmentAPI/issues)
+[](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"]