",
+ "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/__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..37b5e4a 100644
--- a/app/core/config.py
+++ b/app/core/config.py
@@ -1,38 +1,50 @@
import logging
import os
-import sys
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:
- def __init__(self):
+ SEED: str
+ API_KEY: str
+ WALLET_VERSION: WalletVersion
+
+ 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 env_path.exists():
+ load_dotenv(env_path)
- if not env_path.exists():
- logger.error(".env file not found!")
- sys.exit(1)
+ 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)}. "
+ "Copy .env.example to .env and fill in SEED and API_KEY."
+ )
- load_dotenv(env_path)
+ self.SEED = os.getenv("SEED", "").strip()
+ self.API_KEY = os.getenv("API_KEY", "").strip()
- required_keys = ["SEED", "API_KEY"]
- missing_keys: list[str] = []
-
- for key in required_keys:
- value = os.getenv(key, "").strip()
- if not value:
- missing_keys.append(key)
- setattr(self, key, value)
-
- 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")
+ 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()
+config: Config | None = None
+try:
+ config = Config()
+except ConfigError as e:
+ logger.warning("Configuration not loaded: %s", e)
diff --git a/app/core/constants.py b/app/core/constants.py
new file mode 100644
index 0000000..41812a7
--- /dev/null
+++ b/app/core/constants.py
@@ -0,0 +1,47 @@
+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"
+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-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..1e2d4a0
--- /dev/null
+++ b/app/core/cookies.py
@@ -0,0 +1,34 @@
+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..08c986f 100644
--- a/app/core/logging.py
+++ b/app/core/logging.py
@@ -1,34 +1,21 @@
import logging
-from app.__meta__ import APP_NAME
-
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(
- f"{APP_NAME}.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("aiogram.dispatcher").setLevel(logging.INFO)
- logging.getLogger("aiogram.event").setLevel(logging.ERROR)
- logging.getLogger("httpx").setLevel(logging.INFO)
+ logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
diff --git a/app/methods/__init__.py b/app/methods/__init__.py
index fc72eef..590e0e7 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..a6004f9 100644
--- a/app/methods/premium.py
+++ b/app/methods/premium.py
@@ -1,133 +1,138 @@
-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:
+ 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:
- 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"}
-
- 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'
- }
-
- request_success, transaction_result = await self.api_client.execute_transaction_request(
- tx_data,
- account,
- fragment_hash,
+ logger.info("Searching recipient: %s", username)
+ recipient = await search_premium_recipient(
+ client, fragment_hash, cookies, username, months
)
- if not request_success:
- return transaction_result
+ logger.info("Initializing Premium gift request: %s months to %s", months, username)
+ req_id = await init_gift_premium(client, fragment_hash, cookies, recipient, months)
- 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())
- }
+ # logger.info("Requesting transaction payload (req_id=%s)", req_id)
+ tx_data = {
+ "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
+ )
- return {"success": False, "error": error}
+ logger.info("Broadcasting transaction to TON blockchain")
+ tx_hash = await process_transaction(transaction)
+ return {
+ "success": True,
+ "data": {
+ "transaction_id": tx_hash,
+ "username": username,
+ "months": months,
+ "timestamp": int(time.time()),
+ },
+ }
+
+ 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..2dbf05e 100644
--- a/app/methods/stars.py
+++ b/app/methods/stars.py
@@ -1,125 +1,124 @@
-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:
+ 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:
- 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)
+ logger.info("Searching recipient: %s", username)
+ recipient = await search_stars_recipient(client, fragment_hash, cookies, username)
- 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"}
+ 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': 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",
}
-
- request_success, transaction_result = await self.api_client.execute_transaction_request(
- tx_data,
- account,
- fragment_hash,
+ transaction = await execute_transaction_request(
+ client, HEADERS, cookies, account, tx_data, fragment_hash
)
- if not request_success:
- return transaction_result
+ logger.info("Broadcasting transaction to TON blockchain")
+ tx_hash = await process_transaction(transaction)
+ return {
+ "success": True,
+ "data": {
+ "transaction_id": tx_hash,
+ "username": username,
+ "amount": amount,
+ "timestamp": int(time.time()),
+ },
+ }
- 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..60b3932 100644
--- a/app/methods/ton.py
+++ b/app/methods/ton.py
@@ -1,132 +1,130 @@
-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:
+ 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:
- 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)
+ logger.info("Searching recipient: %s", username)
+ recipient = await search_ads_recipient(client, fragment_hash, cookies, username)
- 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"}
+ 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': 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",
}
-
- request_success, transaction_result = await self.api_client.execute_transaction_request(
- tx_data,
- account,
- fragment_hash,
+ transaction = await execute_transaction_request(
+ client, HEADERS, cookies, account, tx_data, fragment_hash
)
- if not request_success:
- return transaction_result
+ logger.info("Broadcasting transaction to TON blockchain")
+ tx_hash = await process_transaction(transaction)
+ return {
+ "success": True,
+ "data": {
+ "transaction_id": tx_hash,
+ "username": username,
+ "amount": amount,
+ "timestamp": int(time.time()),
+ },
+ }
- 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..b2d51d5 100644
--- a/app/utils/__init__.py
+++ b/app/utils/__init__.py
@@ -1,16 +1,14 @@
-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.wallet import get_account_info, link_wallet, process_transaction
__all__ = [
- 'TransactionProcessor',
- 'WalletLinker',
- 'ApiClient',
- 'clean_decode',
- 'parse_json_response',
- 'load_cookies',
- 'get_fragment_hash'
+ "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 808fd9a..694d40f 100644
--- a/app/utils/client.py
+++ b/app/utils/client.py
@@ -3,47 +3,41 @@ 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 True, transaction
+ return 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..70097b2 100644
--- a/app/utils/decoder.py
+++ b/app/utils/decoder.py
@@ -1,40 +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(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}")
+ # Pad and decode base64 → BOC bytes
+ s = payload.strip()
+ if not s:
+ return ""
+ s += "=" * (-len(s) % 4)
+ boc = base64.b64decode(s)
- # 1. Clean Base64
- s = re.sub(r'[^A-Za-z0-9+/=]', '', payload.strip())
- s += '=' * (-len(s) % 4)
-
- # 2. Base64 -> bytes
- b = base64.b64decode(s)
-
- # 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}")
+ # 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 payload: %s", result.replace("\n", " "))
return result
diff --git a/app/utils/hash.py b/app/utils/hash.py
index 5414ff1..a09455d 100644
--- a/app/utils/hash.py
+++ b/app/utils/hash.py
@@ -4,32 +4,48 @@ from typing import Any
import httpx
+from app.core.exceptions import HashFetchError
+
logger = logging.getLogger(__name__)
async def get_fragment_hash(
- cookies: dict[str, Any],
- headers: dict[str, str],
- page_url: str,
-) -> str | None:
- request_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/",
+ 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")
}
+ 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=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
deleted file mode 100644
index 63b4655..0000000
--- a/app/utils/transaction.py
+++ /dev/null
@@ -1,58 +0,0 @@
-import logging
-
-from tonutils.client import TonapiClient, ToncenterV3Client
-from tonutils.wallet import WalletV5R1
-from tonutils.wallet.messages import TransferMessage
-
-from app.core import config
-
-logger = logging.getLogger(__name__)
-
-
-class TransactionProcessor:
- def __init__(self, clean_decode_func):
- self._clean_decode = clean_decode_func
-
- @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)
-
- try:
- balance = await wallet.balance()
- except Exception as exc:
- return False, f"Wallet balance check failed: {exc}"
-
- try:
- if float(balance) <= 0:
- return False, "Wallet balance is zero"
- except Exception:
- pass
-
- return True, None
-
- 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..01932cd 100644
--- a/app/utils/wallet.py
+++ b/app/utils/wallet.py
@@ -1,31 +1,114 @@
+import base64
+import json
+import logging
from typing import Any
import httpx
+from tonutils.clients import TonapiClient
+from tonutils.types import NetworkGlobalID
+
+from app.core import config
+from app.core.constants import DEVICE, WALLET_CLASSES
+from app.core.exceptions import TransactionError, WalletError
+from app.utils.decoder import clean_decode
+
+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
+def initialize_ton_client() -> TonapiClient:
+ return TonapiClient(network=NetworkGlobalID.MAINNET, api_key=config.API_KEY)
- 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'
+
+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."
+ )
+
+ # 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:
+ message = transaction_data["transaction"]["messages"][0]
+ payload = clean_decode(message["payload"])
+
+ 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
+
+
+async def get_account_info() -> dict[str, Any]:
+ 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
- response = await client.post(f"https://fragment.com/api?hash={fragment_hash}",
- headers=self.headers, cookies=self.cookies, data=data)
- result = response.json()
- if result.get("ok"):
- return True
+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 "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/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 e3ca22d..d03d4a7 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,13 +24,14 @@ 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"]
- 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']}")
@@ -40,9 +40,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..ab531a7 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[pytoniq]==2.0.0
diff --git a/tests/001_test_decode.py b/tests/001_test_decode.py
new file mode 100644
index 0000000..408de53
--- /dev/null
+++ b/tests/001_test_decode.py
@@ -0,0 +1,35 @@
+"""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..b1b50ec
--- /dev/null
+++ b/tests/002_test_hash.py
@@ -0,0 +1,17 @@
+"""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/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"]
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}")