Merge pull request #2 from bohd4nx/refactor/tonutils-v2

Refactor/tonutils v2
This commit is contained in:
Bohdan 👾
2026-03-05 06:27:52 +02:00
committed by GitHub
32 changed files with 981 additions and 686 deletions
+4 -3
View File
@@ -1,10 +1,11 @@
# 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 = "your_fragment_hash_here" --- IGNORE ---
# Hash is now fetched dynamically, so this line is no longer needed.
# Hash is now fetched dynamically
# TON wallet seed phrase - 12 or 24 words separated by spaces
SEED = "your_ton_wallet_seed_phrase_here"
# TON API key - get from https://tonconsole.com
API_KEY = "your_ton_api_key_here"
# TON wallet contract version: V4R2 or V5R1 (default: V5R1)
WALLET_VERSION = "V5R1"
+10
View File
@@ -0,0 +1,10 @@
version: 2
updates:
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 5
labels:
- "dependencies"
+28
View File
@@ -0,0 +1,28 @@
name: Tests
on:
push:
branches: ["**"]
pull_request:
branches: ["**"]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: "pip"
- name: Install dependencies
run: pip install -r requirements.txt pytest pytest-asyncio
- name: Run tests
run: pytest
env:
SEED: ${{ secrets.SEED }}
API_KEY: ${{ secrets.API_KEY }}
+2 -5
View File
@@ -20,10 +20,7 @@ logs/
# Environment variables
.env
# Test files
tests/
*.test.py
# System files
.DS_Store
Thumbs.db
Thumbs.db
cookies.json
+102 -80
View File
@@ -7,8 +7,13 @@
<b>Automate TON topups, Telegram Premium purchases, and Stars transactions via Fragment.com</b>
</p>
[Report Bug](https://github.com/bohd4nx/fragmentapi/issues) · [Request Feature](https://github.com/bohd4nx/fragmentapi/issues) · [
**Donate TON**](https://app.tonkeeper.com/transfer/UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5)
[![Python](https://img.shields.io/badge/Python-3.10+-3776AB?style=flat&logo=python&logoColor=white)](https://python.org)
[![tonutils](https://img.shields.io/badge/tonutils-2.0.0-0098EA?style=flat&logo=ton&logoColor=white)](https://github.com/nessshon/tonutils)
[![Stars](https://img.shields.io/github/stars/bohd4nx/FragmentAPI?style=flat&color=yellow)](https://github.com/bohd4nx/FragmentAPI/stargazers)
[![Issues](https://img.shields.io/github/issues/bohd4nx/FragmentAPI?style=flat&color=red)](https://github.com/bohd4nx/FragmentAPI/issues)
[![CI](https://img.shields.io/github/actions/workflow/status/bohd4nx/FragmentAPI/tests.yml?style=flat&label=tests&logo=github)](https://github.com/bohd4nx/FragmentAPI/actions)
[Report Bug](https://github.com/bohd4nx/fragmentapi/issues) · [Request Feature](https://github.com/bohd4nx/fragmentapi/issues) · [**Donate TON**](https://app.tonkeeper.com/transfer/UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5)
</div>
@@ -16,9 +21,10 @@
## ✨ Features
- 💰 **TON Advertisement Topups** - Send TON for advertising campaigns and purchasing gifts (1-1,000,000,000 TON)
- 👑 **Telegram Premium Gifts** - Purchase Premium subscriptions (3, 6, or 12 months)
-**Telegram Stars Purchases** - Buy Stars for users (50-1,000,000 Stars)
- 💰 **TON Advertisement Topups** Send TON directly to Fragment ad accounts (11,000,000,000 TON)
- 👑 **Telegram Premium Gifts** Purchase Premium subscriptions for any user (3, 6, or 12 months)
-**Telegram Stars Purchases** Buy Stars and send them to any Telegram user (501,000,000 Stars)
- 🔐 **Multi-wallet support** — Configurable wallet contract version (V4R2 / V5R1)
## 🚀 Quick Start
@@ -32,88 +38,82 @@ pip install -r requirements.txt
### 2. Configuration
Copy example configuration and edit:
```bash
cp .env.example .env
cp cookies.example.json cookies.json
```
Edit `.env` file:
Edit `.env`:
```env
SEED=word1 word2 word3 ... word24
# 24-word TON wallet seed phrase
SEED = word1 word2 word3 ... word24
API_KEY=your_ton_api_key_here
# API key from @tonapibot on Telegram
API_KEY = your_tonapi_key_here
# Wallet contract version: V4R2 or V5R1 (default: V5R1)
WALLET_VERSION = V5R1
```
### 3. Getting Required Data
#### 🍪 Fragment.com Cookies
**Prerequisites**: Login to your Telegram account and connect the TON wallet you want to use for payments.
**Prerequisites**: Log in to Telegram on Fragment and connect the TON wallet you'll use for payments.
1. **Install Cookie Editor Extension**:
- Download
from [Chrome Web Store](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm)
- Add extension to your browser
2. **Extract Cookies**:
- Open [Fragment.com](https://fragment.com) and ensure you're logged in
- Refresh the page completely
- Click on the Cookie Editor extension icon
- Click **"Export"** button
- Select **"Header String"** format
- Copy the result and split it into JSON fields in `cookies.json`
**Expected format** (`cookies.json` in project root):
1. Install [Cookie Editor](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm) extension
2. Open [fragment.com](https://fragment.com) and make sure you're logged in
3. Click the Cookie Editor icon → **Export****Header String**
4. Split the result into the four fields in `cookies.json`:
```json
{
"stel_ssid": "<SSID>",
"stel_dt": "<STEL_DT>",
"stel_token": "<TOKEN>",
"stel_ton_token": "<TON_TOKEN>"
"stel_ssid": "...",
"stel_dt": "...",
"stel_token": "...",
"stel_ton_token": "..."
}
```
#### 🔐 TON Wallet Seed Phrase
**If you don't have a TON wallet yet**:
If you don't have a TON wallet, create one in [Tonkeeper](https://tonkeeper.com) (iOS / Android).
Go to **Settings → Backup**, copy the 24 words and paste them into `SEED` in `.env`.
1. **Download Tonkeeper**:
- iOS: [App Store](https://apps.apple.com/app/tonkeeper/id1587742107)
- Android: [Google Play](https://play.google.com/store/apps/details?id=com.ton_keeper)
2. **Create New Wallet**:
- Open Tonkeeper app
- Tap **"Create New Wallet"**
- **IMPORTANT**: Write down your 24-word seed phrase on paper
- Store it securely - never share with anyone!
- Complete wallet setup
3. **Get Your Seed Phrase**:
- If you already have a wallet, go to Settings → Backup
- Enter your passcode
- Copy the 24 words → paste to `SEED` in your `.env` file
**Format**: `word1 word2 word3 ... word24`
#### 🔗 Fragment Hash
Hash is fetched automatically from Fragment pages at runtime. You no longer need to add `HASH` to `.env`.
> ⚠️ Never share your seed phrase with anyone. Store it offline.
#### 🔑 TON API Key
1. **Get API Key**:
- Visit [TON Console](https://tonconsole.com)
- Create account and login
- Generate new API key
- Copy the key → paste to `API_KEY` in your `.env` file
1. Go to [tonconsole.com](https://tonconsole.com)
2. Create an account and log in
3. Generate a new API key
4. Paste it into `API_KEY` in `.env`
**Alternative**: You can also use [TON API](https://tonapi.io) for getting API key.
#### 🔐 Wallet Version
| Version | Use when |
| ------- | -------------------------------------------------------------- |
| `V5R1` | Default — Tonkeeper / MyTonWallet (wallets created after 2024) |
| `V4R2` | Older Tonkeeper wallets |
Not sure? Run this to check which address matches your wallet:
```bash
python3 -c "
import asyncio
from tonutils.clients import TonapiClient
from tonutils.contracts.wallet import WalletV4R2, WalletV5R1
from tonutils.types import NetworkGlobalID
from app.core import config
client = TonapiClient(network=NetworkGlobalID.MAINNET, api_key=config.API_KEY)
w4, _, _, _ = WalletV4R2.from_mnemonic(client=client, mnemonic=config.SEED)
w5, _, _, _ = WalletV5R1.from_mnemonic(client=client, mnemonic=config.SEED)
print('V4R2:', w4.address.to_str(True, True))
print('V5R1:', w5.address.to_str(True, True))
"
```
### 4. Usage
@@ -126,35 +126,57 @@ python main.py
#### Programmatic Usage
```python
from app.methods import FragmentTon, FragmentPremium, FragmentStars
import asyncio
from app.methods import topup_ton, buy_premium, buy_stars
# TON topup for ads
ton_client = FragmentTon()
result = await ton_client.topup_ton("@username", 5)
async def main():
# Send 10 TON to @username
result = await topup_ton("@username", 10)
print(result)
# Premium purchase
premium_client = FragmentPremium()
result = await premium_client.buy_premium("@username", 6)
# Gift 6 months of Telegram Premium
result = await buy_premium("@username", 6)
print(result)
# Stars purchase
stars_client = FragmentStars()
result = await stars_client.buy_stars("@username", 50)
# Buy 500 Stars for @username
result = await buy_stars("@username", 500)
print(result)
asyncio.run(main())
```
**Return format** (on success):
```python
{
"success": True,
"data": {
"transaction_id": "<TL-B ExternalMessage ...>",
"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 | 11,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 | 501,000,000 Stars |
### Username Formats
All methods accept various username formats:
- `@username` (with @)
- `username` (without @)
Usernames can be passed with or without `@`.
<div align="center">
-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
__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",
]
+34 -22
View File
@@ -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)
+47
View File
@@ -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",
}
+34
View File
@@ -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
+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."""
+4 -17
View File
@@ -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)
+4 -4
View File
@@ -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"]
+116 -111
View File
@@ -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}"}
+100 -101
View File
@@ -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}"}
+106 -108
View File
@@ -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}"}
+9 -11
View File
@@ -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",
]
+31 -37
View File
@@ -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
-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 {}
+27 -29
View File
@@ -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
+34 -18
View File
@@ -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)
-58
View File
@@ -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
+102 -19
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
{
"stel_ssid": "",
"stel_dt": "",
"stel_token": "",
"stel_ton_token": ""
}
+10 -11
View File
@@ -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"]
+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
httpx==0.28.1
tonutils==0.5.6
tonutils[pytoniq]==2.0.0
+35
View File
@@ -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)
+17
View File
@@ -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}"
+13
View File
@@ -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"]
View File
+13
View File
@@ -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}")