refactor: migrate to tonutils v2, pytest, pyproject.toml

- 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
This commit is contained in:
bohd4nx
2026-03-05 04:08:35 +02:00
parent b1eae1dcb7
commit e3706f01bf
23 changed files with 631 additions and 565 deletions
+1 -3
View File
@@ -1,7 +1,5 @@
# Fragment.com cookies - copy from browser after login (Header String format) # Fragment.com cookies - copy from browser after login (Header String format)
# How to get fragment hash: open devtools -> Network -> api?hash=<your_fragment_hash_here> # Hash is now fetched dynamically
# HASH = "your_fragment_hash_here" --- IGNORE ---
# Hash is now fetched dynamically, so this line is no longer needed.
# TON wallet seed phrase - 12 or 24 words separated by spaces # TON wallet seed phrase - 12 or 24 words separated by spaces
SEED = "your_ton_wallet_seed_phrase_here" SEED = "your_ton_wallet_seed_phrase_here"
+1 -1
View File
@@ -26,4 +26,4 @@ tests/
# System files # System files
.DS_Store .DS_Store
Thumbs.db Thumbs.db
-13
View File
@@ -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",
]
+32 -2
View File
@@ -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 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",
]
+23 -19
View File
@@ -5,34 +5,38 @@ from pathlib import Path
from dotenv import load_dotenv from dotenv import load_dotenv
from app.core.exceptions import ConfigError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class Config: class Config:
def __init__(self): SEED: str
API_KEY: str
def __init__(self) -> None:
env_path = Path(__file__).resolve().parents[2] / ".env" env_path = Path(__file__).resolve().parents[2] / ".env"
if not env_path.exists(): if not env_path.exists():
logger.error(".env file not found!") raise ConfigError(
sys.exit(1) ".env file not found. "
"Copy .env.example to .env and fill in SEED and API_KEY."
)
load_dotenv(env_path) load_dotenv(env_path)
required_keys = ["SEED", "API_KEY"] missing = [k for k in ("SEED", "API_KEY") if not os.getenv(k, "").strip()]
missing_keys: list[str] = [] if missing:
raise ConfigError(
f"Missing required environment variables: {', '.join(missing)}. "
"Open .env and fill in all required fields."
)
for key in required_keys: self.SEED = os.getenv("SEED", "").strip()
value = os.getenv(key, "").strip() self.API_KEY = os.getenv("API_KEY", "").strip()
if not value:
missing_keys.append(key)
setattr(self, key, value)
if missing_keys: try:
logger.error(f"Missing required environment variables: {', '.join(missing_keys)}") config = Config()
logger.error("Create .env file based on .env.example and fill all fields") except ConfigError as e:
sys.exit(1) logger.error("Configuration error: %s", e)
sys.exit(1)
logger.info("Configuration loaded successfully")
config = Config()
+38
View File
@@ -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",
}
+35
View File
@@ -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
+42
View File
@@ -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."""
+1 -5
View File
@@ -1,7 +1,5 @@
import logging import logging
from app.__meta__ import APP_NAME
def setup_logging() -> None: def setup_logging() -> None:
formatter = logging.Formatter( formatter = logging.Formatter(
@@ -14,7 +12,7 @@ def setup_logging() -> None:
console_handler.setFormatter(formatter) console_handler.setFormatter(formatter)
file_handler = logging.FileHandler( file_handler = logging.FileHandler(
f"{APP_NAME}.log", "FragmentAPI.log",
mode="w", mode="w",
encoding="utf-8" encoding="utf-8"
) )
@@ -26,8 +24,6 @@ def setup_logging() -> None:
force=True force=True
) )
logging.getLogger("aiogram.dispatcher").setLevel(logging.INFO)
logging.getLogger("aiogram.event").setLevel(logging.ERROR)
logging.getLogger("httpx").setLevel(logging.INFO) logging.getLogger("httpx").setLevel(logging.INFO)
logging.getLogger("httpcore").setLevel(logging.WARNING) logging.getLogger("httpcore").setLevel(logging.WARNING)
+4 -4
View File
@@ -1,5 +1,5 @@
from app.methods.premium import FragmentPremium from app.methods.premium import buy_premium
from app.methods.stars import FragmentStars from app.methods.stars import buy_stars
from app.methods.ton import FragmentTon from app.methods.ton import topup_ton
__all__ = ['FragmentTon', 'FragmentPremium', 'FragmentStars'] __all__ = ['buy_premium', 'buy_stars', 'topup_ton']
+94 -111
View File
@@ -1,133 +1,116 @@
import base64 import json
import logging import logging
import time import time
import httpx 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 ( from app.utils import (
TransactionProcessor, execute_transaction_request,
WalletLinker, get_account_info,
ApiClient,
clean_decode,
parse_json_response,
load_cookies,
get_fragment_hash, get_fragment_hash,
parse_json_response,
process_transaction,
) )
logger = logging.getLogger(__name__) 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() async def search_premium_recipient(
client: httpx.AsyncClient,
self.transaction_processor = TransactionProcessor(clean_decode) fragment_hash: str,
self.wallet_linker = WalletLinker(self.headers, self.cookies, self.transaction_processor) cookies: dict,
self.api_client = ApiClient(self.headers, self.cookies, self.wallet_linker) username: str,
months: int,
@staticmethod ) -> str:
async def _get_account_info(): resp = await client.post(
client = TonapiClient(api_key=config.API_KEY, is_testnet=False) f"https://fragment.com/api?hash={fragment_hash}",
wallet, pub_key, _, _ = WalletV5R1.from_mnemonic(client=client, mnemonic=config.SEED) headers=HEADERS, cookies=cookies,
boc = wallet.state_init.serialize().to_boc() data={"query": username, "months": months, "method": "searchPremiumGiftRecipient"},
)
return { result = parse_json_response(resp, "searchPremiumGiftRecipient")
"address": wallet.address.to_str(False, False), recipient = result.get("found", {}).get("recipient")
"publicKey": pub_key.hex(), if not recipient:
"chain": "-239", raise UserNotFoundError(
"walletStateInit": base64.b64encode(boc).decode() f"Telegram user '{username}' was not found on Fragment. "
} "Make sure the username is correct and the account exists."
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",
) )
if not fragment_hash: return recipient
raise RuntimeError("Failed to fetch Fragment hash")
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: async with httpx.AsyncClient() as client:
search_data = {"query": username, "months": months, "method": "searchPremiumGiftRecipient"} recipient = await search_premium_recipient(client, fragment_hash, cookies, username, months)
search_resp = await client.post(f"https://fragment.com/api?hash={fragment_hash}", req_id = await init_gift_premium(client, fragment_hash, cookies, recipient, months)
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"}
tx_data = { tx_data = {
'account': account, "account": json.dumps(account),
'device': {"appVersion": "5.4.3", "platform": "iphone", "device": DEVICE,
"features": ["SendTransaction", {"maxMessages": 255, "name": "SendTransaction"}, "transaction": 1,
{"types": ["text", "binary", "cell"], "name": "SignData"}], "id": req_id,
"appName": "Tonkeeper", "maxProtocolVersion": 2}, "show_sender": 1,
'transaction': 1, "method": "getGiftPremiumLink",
'id': req_id,
'show_sender': 1,
'ref': "OprzztcdJ",
'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_hash = await process_transaction(transaction)
tx_data, return {
account, "success": True,
fragment_hash, "data": {
) "transaction_id": tx_hash,
"username": username,
"months": months,
"timestamp": int(time.time()),
},
}
if not request_success: except FragmentError as exc:
return transaction_result logger.error("Premium purchase failed — %s", exc)
return {"success": False, "error": str(exc)}
success, error, tx_hash = await self.transaction_processor.process_transaction(transaction_result) except Exception as exc:
logger.exception("Unexpected error during Premium purchase")
if success: return {"success": False, "error": f"Unexpected error: {exc}"}
return {
"success": True,
"data": {
"transaction_id": tx_hash,
"username": username,
"months": months,
"timestamp": int(time.time())
}
}
return {"success": False, "error": error}
+88 -103
View File
@@ -1,125 +1,110 @@
import base64 import json
import logging import logging
import time import time
import httpx 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 ( from app.utils import (
TransactionProcessor, execute_transaction_request,
WalletLinker, get_account_info,
ApiClient,
clean_decode,
parse_json_response,
load_cookies,
get_fragment_hash, get_fragment_hash,
parse_json_response,
process_transaction,
) )
logger = logging.getLogger(__name__) 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() async def search_stars_recipient(
client: httpx.AsyncClient,
self.transaction_processor = TransactionProcessor(clean_decode) fragment_hash: str,
self.wallet_linker = WalletLinker(self.headers, self.cookies, self.transaction_processor) cookies: dict,
self.api_client = ApiClient(self.headers, self.cookies, self.wallet_linker) username: str,
) -> str:
@staticmethod resp = await client.post(
async def _get_account_info(): f"https://fragment.com/api?hash={fragment_hash}",
client = TonapiClient(api_key=config.API_KEY, is_testnet=False) headers=HEADERS, cookies=cookies,
wallet, pub_key, _, _ = WalletV5R1.from_mnemonic(client=client, mnemonic=config.SEED) data={"query": username, "quantity": "", "method": "searchStarsRecipient"},
boc = wallet.state_init.serialize().to_boc() )
result = parse_json_response(resp, "searchStarsRecipient")
return { recipient = result.get("found", {}).get("recipient")
"address": wallet.address.to_str(False, False), if not recipient:
"publicKey": pub_key.hex(), raise UserNotFoundError(
"chain": "-239", f"Telegram user '{username}' was not found on Fragment. "
"walletStateInit": base64.b64encode(boc).decode() "Make sure the username is correct and the account exists."
}
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",
) )
if not fragment_hash: return recipient
raise RuntimeError("Failed to fetch Fragment hash")
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: async with httpx.AsyncClient() as client:
search_data = {"query": username, "quantity": "", "method": "searchStarsRecipient"} recipient = await search_stars_recipient(client, fragment_hash, cookies, username)
search_resp = await client.post(f"https://fragment.com/api?hash={fragment_hash}", req_id = await init_buy_stars(client, fragment_hash, cookies, recipient, amount)
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"}
tx_data = { tx_data = {
'account': account, "account": json.dumps(account),
'device': "iPhone15,2", "device": DEVICE,
'transaction': 1, "transaction": 1,
'id': req_id, "id": req_id,
'show_sender': 0, "show_sender": 1,
'method': 'getBuyStarsLink' "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_hash = await process_transaction(transaction)
tx_data, return {
account, "success": True,
fragment_hash, "data": {
) "transaction_id": tx_hash,
"username": username,
"amount": amount,
"timestamp": int(time.time()),
},
}
if not request_success: except FragmentError as exc:
return transaction_result logger.error("Stars purchase failed — %s", exc)
return {"success": False, "error": str(exc)}
success, error, tx_hash = await self.transaction_processor.process_transaction(transaction_result) except Exception as exc:
logger.exception("Unexpected error during Stars purchase")
if success: return {"success": False, "error": f"Unexpected error: {exc}"}
return {
"success": True,
"data": {
"transaction_id": tx_hash,
"username": username,
"amount": amount,
"timestamp": int(time.time())
}
}
return {"success": False, "error": error}
+93 -110
View File
@@ -1,132 +1,115 @@
import base64 import json
import logging import logging
import time import time
import httpx 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 ( from app.utils import (
TransactionProcessor, execute_transaction_request,
WalletLinker, get_account_info,
ApiClient,
clean_decode,
parse_json_response,
load_cookies,
get_fragment_hash, get_fragment_hash,
parse_json_response,
process_transaction,
) )
logger = logging.getLogger(__name__) 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() async def search_ads_recipient(
client: httpx.AsyncClient,
self.transaction_processor = TransactionProcessor(clean_decode) fragment_hash: str,
self.wallet_linker = WalletLinker(self.headers, self.cookies, self.transaction_processor) cookies: dict,
self.api_client = ApiClient(self.headers, self.cookies, self.wallet_linker) username: str,
) -> str:
@staticmethod await client.post(
async def _get_account_info(): f"https://fragment.com/api?hash={fragment_hash}",
client = TonapiClient(api_key=config.API_KEY, is_testnet=False) headers=HEADERS, cookies=cookies,
wallet, pub_key, _, _ = WalletV5R1.from_mnemonic(client=client, mnemonic=config.SEED) data={"mode": "new", "method": "updateAdsTopupState"},
boc = wallet.state_init.serialize().to_boc() )
resp = await client.post(
return { f"https://fragment.com/api?hash={fragment_hash}",
"address": wallet.address.to_str(False, False), headers=HEADERS, cookies=cookies,
"publicKey": pub_key.hex(), data={"query": username, "method": "searchAdsTopupRecipient"},
"chain": "-239", )
"walletStateInit": base64.b64encode(boc).decode() result = parse_json_response(resp, "searchAdsTopupRecipient")
} recipient = result.get("found", {}).get("recipient")
if not recipient:
async def topup_ton(self, username, amount): raise UserNotFoundError(
if amount < 1 or not isinstance(amount, int): f"Telegram user '{username}' was not found on Fragment. "
return {"success": False, "error": "Amount must be an integer >= 1 TON"} "Make sure the username is correct and the account exists."
fragment_hash = await get_fragment_hash(
self.cookies,
self.headers,
"https://fragment.com/ads/topup",
) )
if not fragment_hash: return recipient
raise RuntimeError("Failed to fetch Fragment hash")
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: async with httpx.AsyncClient() as client:
update_data = {"mode": "new", "method": "updateAdsTopupState"} recipient = await search_ads_recipient(client, fragment_hash, cookies, username)
await client.post(f"https://fragment.com/api?hash={fragment_hash}", req_id = await init_ads_topup(client, fragment_hash, cookies, recipient, amount)
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"}
tx_data = { tx_data = {
'account': account, "account": json.dumps(account),
'device': {"appVersion": "5.4.3", "platform": "iphone", "device": DEVICE,
"features": ["SendTransaction", {"maxMessages": 255, "name": "SendTransaction"}, "transaction": 1,
{"types": ["text", "binary", "cell"], "name": "SignData"}], "id": req_id,
"appName": "Tonkeeper", "maxProtocolVersion": 2}, "show_sender": 1,
'transaction': 1, "method": "getAdsTopupLink",
'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_hash = await process_transaction(transaction)
tx_data, return {
account, "success": True,
fragment_hash, "data": {
) "transaction_id": tx_hash,
"username": username,
"amount": amount,
"timestamp": int(time.time()),
},
}
if not request_success: except FragmentError as exc:
return transaction_result logger.error("TON topup failed — %s", exc)
return {"success": False, "error": str(exc)}
success, error, tx_hash = await self.transaction_processor.process_transaction(transaction_result) except Exception as exc:
logger.exception("Unexpected error during TON topup")
if success: return {"success": False, "error": f"Unexpected error: {exc}"}
return {
"success": True,
"data": {
"transaction_id": tx_hash,
"username": username,
"amount": amount,
"timestamp": int(time.time())
}
}
return {"success": False, "error": error}
+8 -9
View File
@@ -1,16 +1,15 @@
from app.utils.client import ApiClient, parse_json_response from app.utils.client import execute_transaction_request, parse_json_response
from app.utils.cookies import load_cookies
from app.utils.decoder import clean_decode from app.utils.decoder import clean_decode
from app.utils.hash import get_fragment_hash from app.utils.hash import get_fragment_hash
from app.utils.transaction import TransactionProcessor from app.utils.transaction import process_transaction
from app.utils.wallet import WalletLinker from app.utils.wallet import get_account_info, link_wallet
__all__ = [ __all__ = [
'TransactionProcessor',
'WalletLinker',
'ApiClient',
'clean_decode', 'clean_decode',
'execute_transaction_request',
'get_account_info',
'get_fragment_hash',
'link_wallet',
'parse_json_response', 'parse_json_response',
'load_cookies', 'process_transaction',
'get_fragment_hash'
] ]
+31 -36
View File
@@ -3,47 +3,42 @@ from typing import Any
import httpx import httpx
from app.core.exceptions import RequestError, WalletError
from app.utils.wallet import link_wallet
def parse_json_response( logger = logging.getLogger(__name__)
response: httpx.Response,
logger: logging.Logger,
context: str, def parse_json_response(response: httpx.Response, context: str) -> dict[str, Any]:
) -> tuple[dict[str, Any] | None, str | None]:
try: try:
return response.json(), None return response.json()
except Exception as e: except Exception as exc:
logger.error(f"Failed to parse {context} response: {e}") raise RequestError(
logger.error(f"Response content: {response.content[:200]}") f"Fragment API returned an unparseable response for '{context}': {exc}"
return None, str(e) ) from exc
class ApiClient: async def execute_transaction_request(
def __init__(self, headers: dict, cookies: dict, wallet_linker): client: httpx.AsyncClient,
self.headers = headers headers: dict,
self.cookies = cookies cookies: dict,
self.wallet_linker = wallet_linker 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( resp = await client.post(url, headers=headers, cookies=cookies, data=tx_data)
self, transaction = parse_json_response(resp, tx_data.get("method", "transaction"))
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}"}
if transaction.get("need_verify"): if transaction.get("need_verify"):
if not await self.wallet_linker.link_wallet(account, fragment_hash): if not await link_wallet(client, headers, cookies, account, fragment_hash):
return False, {"success": False, "error": "Failed to link wallet"} 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}", return transaction
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 True, transaction
-35
View File
@@ -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 {}
+10 -24
View File
@@ -7,34 +7,20 @@ logger = logging.getLogger(__name__)
def clean_decode(payload: str) -> str: def clean_decode(payload: str) -> str:
logger.debug(f"Original payload: {payload}") logger.debug("Original payload: %s", payload)
# Decode raw bytes first # Strip non-Base64 chars and decode
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
s = re.sub(r'[^A-Za-z0-9+/=]', '', payload.strip()) s = re.sub(r'[^A-Za-z0-9+/=]', '', payload.strip())
s += '=' * (-len(s) % 4) s += '=' * (-len(s) % 4)
text = base64.b64decode(s).decode('utf-8', errors='ignore')
# 2. Base64 -> bytes # Keep only printable characters
b = base64.b64decode(s) text = ''.join(c for c in text if c in string.printable or c.isspace())
# 3. Decode UTF-8, ignoring invalid bytes # Extract "Telegram … Ref#XXXX" block
t = b.decode('utf-8', errors='ignore') 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()
# 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}")
logger.debug("Decoded result: %s", result)
return result return result
+17 -13
View File
@@ -4,6 +4,8 @@ from typing import Any
import httpx import httpx
from app.core.exceptions import HashFetchError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -11,25 +13,27 @@ async def get_fragment_hash(
cookies: dict[str, Any], cookies: dict[str, Any],
headers: dict[str, str], headers: dict[str, str],
page_url: str, page_url: str,
) -> str | None: ) -> str:
request_headers = { page_headers = {
**headers,
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "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/", "referer": "https://fragment.com/",
} }
async with httpx.AsyncClient(cookies=cookies) as client: 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: if response.status_code != 200:
logger.error("Failed to fetch Fragment page for hash: %s", response.status_code) raise HashFetchError(
return None 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]+)", response.text)
match = re.search(r"(?:https://fragment\.com)?/api\?hash=([a-f0-9]+)", text) if not match:
if match: raise HashFetchError(
return match.group(1) 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 match.group(1)
return None
+35 -44
View File
@@ -1,58 +1,49 @@
import logging import logging
from tonutils.client import TonapiClient, ToncenterV3Client from tonutils.client import ToncenterV3Client
from tonutils.wallet import WalletV5R1 from tonutils.wallet import WalletV5R1
from tonutils.wallet.messages import TransferMessage
from app.core import config from app.core import config
from app.core.exceptions import TransactionError, WalletError
from app.utils.decoder import clean_decode
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class TransactionProcessor: async def process_transaction(transaction_data: dict) -> str:
def __init__(self, clean_decode_func): if "transaction" not in transaction_data or "messages" not in transaction_data["transaction"]:
self._clean_decode = clean_decode_func raise TransactionError(
"Fragment returned an invalid transaction payload. "
"The API response is missing expected 'transaction.messages' data."
)
@staticmethod client = ToncenterV3Client(api_key=config.API_KEY, is_testnet=False)
async def _check_wallet_balance() -> tuple[bool, str | None]: wallet, _, _, _ = WalletV5R1.from_mnemonic(client=client, mnemonic=config.SEED)
client = ToncenterV3Client(is_testnet=False, rps=1, max_retries=1)
wallet, _, _, _ = WalletV5R1.from_mnemonic(client=client, mnemonic=config.SEED)
try: # Check balance before broadcasting
balance = await wallet.balance() try:
except Exception as exc: balance = await wallet.balance()
return False, f"Wallet balance check failed: {exc}" 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: try:
if float(balance) <= 0: message = transaction_data["transaction"]["messages"][0]
return False, "Wallet balance is zero" payload = clean_decode(message["payload"])
except Exception:
pass
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
+52 -21
View File
@@ -1,31 +1,62 @@
import base64
import json
import logging
from typing import Any from typing import Any
import httpx 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: async def get_account_info() -> dict[str, Any]:
def __init__(self, headers: dict, cookies: dict, transaction_processor): try:
self.headers = headers client = ToncenterV3Client(api_key=config.API_KEY, is_testnet=False)
self.cookies = cookies wallet, pub_key, _, _ = WalletV5R1.from_mnemonic(client=client, mnemonic=config.SEED)
self.transaction_processor = transaction_processor 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}", async def link_wallet(
headers=self.headers, cookies=self.cookies, data=data) client: httpx.AsyncClient,
result = response.json() 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"): if result.get("ok"):
return True return True
if "transaction" in result:
success, _, _ = await self.transaction_processor.process_transaction(result)
return success
if "transaction" in result:
try:
await process_transaction(result)
return True
except (TransactionError, WalletError):
return False return False
return False
+7 -10
View File
@@ -1,8 +1,8 @@
import asyncio import asyncio
import logging import logging
from app.methods import FragmentPremium, FragmentStars, FragmentTon
from app.core import setup_logging from app.core import setup_logging
from app.methods import buy_premium, buy_stars, topup_ton
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -10,9 +10,8 @@ logger = logging.getLogger(__name__)
async def topup_ton_example(): async def topup_ton_example():
logger.info("Starting TON topup example") logger.info("Starting TON topup example")
ton_client = FragmentTon() # @bohd4nx - target username, 100 - TON amount (integer 1-1000000000 (one billion))
# @bohd4nx - target username, 5 - TON amount (integer 1-1000000000 (one billion)) result = await topup_ton("@bohd4nx", 100)
result = await ton_client.topup_ton("@bohd4nx", 100)
if result["success"]: if result["success"]:
data = result["data"] data = result["data"]
@@ -25,9 +24,8 @@ async def topup_ton_example():
async def buy_premium_example(): async def buy_premium_example():
logger.info("Starting Premium purchase example") logger.info("Starting Premium purchase example")
premium_client = FragmentPremium() # @bohd4nx - target username, 12 - months duration (3, 6, or 12 only)
# @bohd4nx - target username, 6 - months duration (3, 6, or 12 only) result = await buy_premium("@bohd4nx", 12)
result = await premium_client.buy_premium("@bohd4nx", 12)
if result["success"]: if result["success"]:
data = result["data"] data = result["data"]
@@ -40,9 +38,8 @@ async def buy_premium_example():
async def buy_stars_example(): async def buy_stars_example():
logger.info("Starting Stars purchase example") logger.info("Starting Stars purchase example")
stars_client = FragmentStars() # @bohd4nx - target username, 1000000 - stars amount (integer 50-1000000 (one million))
# @bohd4nx - target username, 50 - stars amount (integer 50-1000000 (one million)) result = await buy_stars("@bohd4nx", 1000000)
result = await stars_client.buy_stars("@bohd4nx", 1000000)
if result["success"]: if result["success"]:
data = result["data"] data = result["data"]
+17
View File
@@ -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"]
+2 -2
View File
@@ -1,4 +1,4 @@
python-dotenv==1.2.1 python-dotenv==1.2.2
asyncio==4.0.0 asyncio==4.0.0
httpx==0.28.1 httpx==0.28.1
tonutils==0.5.6 tonutils==2.0.0