refactor: migrate to tonutils 2.0.0 and restructure codebase

- Migrate from tonutils 0.5.x to 2.0.0 API:
  - ToncenterClient → TonapiClient with NetworkGlobalID
  - tonutils.client → tonutils.clients
  - tonutils.wallet → tonutils.contracts.wallet
  - pub_key.as_hex property, wallet.balance nanotons
  - async with client context manager

- Centralize TON client init into initialize_ton_client() in wallet.py
- Move process_transaction logic into wallet.py, transaction.py re-exports
- Add WALLET_VERSION config (V4R2/V5R1, default V5R1)
- Add WALLET_CLASSES and SUPPORTED_WALLET_VERSIONS to constants.py
- Restore balance check before broadcasting
- Wait for seqno confirmation after transfer (120×2s) to prevent duplicate seqno

- Fix Fragment hash fetching: use browser navigation headers
- Remove accept-encoding from BASE_HEADERS (httpx handles decompression)
- Add cookies.example.json template
- Add cookies.json to .gitignore
This commit is contained in:
bohd4nx
2026-03-05 05:47:27 +02:00
parent b9c02646be
commit efcdda6b74
19 changed files with 274 additions and 159 deletions
+4 -1
View File
@@ -4,5 +4,8 @@
# TON wallet seed phrase - 12 or 24 words separated by spaces
SEED = "your_ton_wallet_seed_phrase_here"
# TON API key - get from https://tonconsole.com
# TON API key - get from https://t.me/tonapibot
API_KEY = "your_ton_api_key_here"
# TON wallet contract version: V4R2 or V5R1 (default: V5R1)
WALLET_VERSION = "V5R1"
+13
View File
@@ -1,17 +1,22 @@
import logging
import os
from pathlib import Path
from typing import Literal
from dotenv import load_dotenv
from app.core.constants import SUPPORTED_WALLET_VERSIONS
from app.core.exceptions import ConfigError
logger = logging.getLogger(__name__)
WalletVersion = Literal["V4R2", "V5R1"]
class Config:
SEED: str
API_KEY: str
WALLET_VERSION: WalletVersion
def __init__(self) -> None:
# Load .env if present; env vars already in the process take precedence
@@ -29,6 +34,14 @@ class Config:
self.SEED = os.getenv("SEED", "").strip()
self.API_KEY = os.getenv("API_KEY", "").strip()
version = os.getenv("WALLET_VERSION", "V5R1").strip().upper()
if version not in SUPPORTED_WALLET_VERSIONS:
raise ConfigError(
f"Unsupported WALLET_VERSION '{version}'. "
f"Must be one of: {', '.join(sorted(SUPPORTED_WALLET_VERSIONS))}."
)
self.WALLET_VERSION: WalletVersion = version # type: ignore[assignment]
config: Config | None = None
try:
+12 -2
View File
@@ -1,12 +1,21 @@
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({
DEVICE: str = json.dumps(
{
"platform": "iphone",
"appName": "Tonkeeper",
"appVersion": "5.5.2",
@@ -16,7 +25,8 @@ DEVICE: str = json.dumps({
{"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".
+1 -2
View File
@@ -15,8 +15,7 @@ def load_cookies() -> dict[str, Any]:
if not cookies_path.exists():
raise CookiesError(
"cookies.json not found. "
"Create it in the project root and paste your Fragment cookies."
"cookies.json not found. Create it in the project root and paste your Fragment cookies."
)
try:
+3 -12
View File
@@ -3,26 +3,17 @@ import logging
def setup_logging() -> None:
formatter = logging.Formatter(
fmt="[%(asctime)s] - %(levelname)s: %(message)s",
datefmt="%d.%m.%y %H:%M:%S"
fmt="[%(asctime)s] - %(levelname)s: %(message)s", datefmt="%d.%m.%y %H:%M:%S"
)
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
console_handler.setFormatter(formatter)
file_handler = logging.FileHandler(
"FragmentAPI.log",
mode="w",
encoding="utf-8"
)
file_handler = logging.FileHandler("FragmentAPI.log", mode="w", encoding="utf-8")
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(formatter)
logging.basicConfig(
level=logging.DEBUG,
handlers=[console_handler, file_handler],
force=True
)
logging.basicConfig(level=logging.DEBUG, handlers=[console_handler, file_handler], force=True)
logging.getLogger("httpx").setLevel(logging.INFO)
logging.getLogger("httpcore").setLevel(logging.WARNING)
+1 -1
View File
@@ -2,4 +2,4 @@ from app.methods.premium import buy_premium
from app.methods.stars import buy_stars
from app.methods.ton import topup_ton
__all__ = ['buy_premium', 'buy_stars', 'topup_ton']
__all__ = ["buy_premium", "buy_stars", "topup_ton"]
+18 -6
View File
@@ -34,7 +34,8 @@ async def search_premium_recipient(
) -> str:
resp = await client.post(
f"https://fragment.com/api?hash={fragment_hash}",
headers=HEADERS, cookies=cookies,
headers=HEADERS,
cookies=cookies,
data={"query": username, "months": months, "method": "searchPremiumGiftRecipient"},
)
result = parse_json_response(resp, "searchPremiumGiftRecipient")
@@ -56,12 +57,19 @@ async def init_gift_premium(
) -> str:
await client.post(
f"https://fragment.com/api?hash={fragment_hash}",
headers=HEADERS, cookies=cookies,
data={"mode": "new", "lv": "false", "dh": str(int(time.time())), "method": "updatePremiumState"},
headers=HEADERS,
cookies=cookies,
data={
"mode": "new",
"lv": "false",
"dh": str(int(time.time())),
"method": "updatePremiumState",
},
)
resp = await client.post(
f"https://fragment.com/api?hash={fragment_hash}",
headers=HEADERS, cookies=cookies,
headers=HEADERS,
cookies=cookies,
data={"recipient": recipient, "months": months, "method": "initGiftPremiumRequest"},
)
result = parse_json_response(resp, "initGiftPremiumRequest")
@@ -84,7 +92,9 @@ async def buy_premium(username: str, months: int) -> dict:
account = await get_account_info()
async with httpx.AsyncClient() as client:
recipient = await search_premium_recipient(client, fragment_hash, cookies, username, months)
recipient = await search_premium_recipient(
client, fragment_hash, cookies, username, months
)
req_id = await init_gift_premium(client, fragment_hash, cookies, recipient, months)
tx_data = {
@@ -95,7 +105,9 @@ async def buy_premium(username: str, months: int) -> dict:
"show_sender": 1,
"method": "getGiftPremiumLink",
}
transaction = await execute_transaction_request(client, HEADERS, cookies, account, tx_data, fragment_hash)
transaction = await execute_transaction_request(
client, HEADERS, cookies, account, tx_data, fragment_hash
)
tx_hash = await process_transaction(transaction)
return {
+7 -3
View File
@@ -33,7 +33,8 @@ async def search_stars_recipient(
) -> str:
resp = await client.post(
f"https://fragment.com/api?hash={fragment_hash}",
headers=HEADERS, cookies=cookies,
headers=HEADERS,
cookies=cookies,
data={"query": username, "quantity": "", "method": "searchStarsRecipient"},
)
result = parse_json_response(resp, "searchStarsRecipient")
@@ -55,7 +56,8 @@ async def init_buy_stars(
) -> str:
resp = await client.post(
f"https://fragment.com/api?hash={fragment_hash}",
headers=HEADERS, cookies=cookies,
headers=HEADERS,
cookies=cookies,
data={"recipient": recipient, "quantity": amount, "method": "initBuyStarsRequest"},
)
result = parse_json_response(resp, "initBuyStarsRequest")
@@ -89,7 +91,9 @@ async def buy_stars(username: str, amount: int) -> dict:
"show_sender": 1,
"method": "getBuyStarsLink",
}
transaction = await execute_transaction_request(client, HEADERS, cookies, account, tx_data, fragment_hash)
transaction = await execute_transaction_request(
client, HEADERS, cookies, account, tx_data, fragment_hash
)
tx_hash = await process_transaction(transaction)
return {
+9 -4
View File
@@ -33,12 +33,14 @@ async def search_ads_recipient(
) -> str:
await client.post(
f"https://fragment.com/api?hash={fragment_hash}",
headers=HEADERS, cookies=cookies,
headers=HEADERS,
cookies=cookies,
data={"mode": "new", "method": "updateAdsTopupState"},
)
resp = await client.post(
f"https://fragment.com/api?hash={fragment_hash}",
headers=HEADERS, cookies=cookies,
headers=HEADERS,
cookies=cookies,
data={"query": username, "method": "searchAdsTopupRecipient"},
)
result = parse_json_response(resp, "searchAdsTopupRecipient")
@@ -60,7 +62,8 @@ async def init_ads_topup(
) -> str:
resp = await client.post(
f"https://fragment.com/api?hash={fragment_hash}",
headers=HEADERS, cookies=cookies,
headers=HEADERS,
cookies=cookies,
data={"recipient": recipient, "amount": amount, "method": "initAdsTopupRequest"},
)
result = parse_json_response(resp, "initAdsTopupRequest")
@@ -94,7 +97,9 @@ async def topup_ton(username: str, amount: int) -> dict:
"show_sender": 1,
"method": "getAdsTopupLink",
}
transaction = await execute_transaction_request(client, HEADERS, cookies, account, tx_data, fragment_hash)
transaction = await execute_transaction_request(
client, HEADERS, cookies, account, tx_data, fragment_hash
)
tx_hash = await process_transaction(transaction)
return {
+8 -9
View File
@@ -1,15 +1,14 @@
from app.utils.client import execute_transaction_request, parse_json_response
from app.utils.decoder import clean_decode
from app.utils.hash import get_fragment_hash
from app.utils.transaction import process_transaction
from app.utils.wallet import get_account_info, link_wallet
from app.utils.wallet import get_account_info, link_wallet, process_transaction
__all__ = [
'clean_decode',
'execute_transaction_request',
'get_account_info',
'get_fragment_hash',
'link_wallet',
'parse_json_response',
'process_transaction',
"clean_decode",
"execute_transaction_request",
"get_account_info",
"get_fragment_hash",
"link_wallet",
"parse_json_response",
"process_transaction",
]
-1
View File
@@ -41,4 +41,3 @@ async def execute_transaction_request(
transaction = parse_json_response(resp, tx_data.get("method", "transaction"))
return transaction
+1 -1
View File
@@ -34,5 +34,5 @@ def clean_decode(payload: str) -> str:
sl.load_uint(32) # op code — always 0 for text comment
result = sl.load_snake_string().strip()
logger.debug("Decoded result: %s", result)
logger.debug("Decoded payload: %s", result.replace("\n", " "))
return result
+8 -4
View File
@@ -17,16 +17,20 @@ async def get_fragment_hash(
# Must look like a real browser navigation — not an XHR — otherwise Fragment
# returns JSON (no hash in it) instead of full HTML.
page_headers = {
k: v for k, v in headers.items()
if k not in ("accept", "accept-encoding", "content-type", "x-requested-with", "x-aj-referer")
k: v
for k, v in headers.items()
if k
not in ("accept", "accept-encoding", "content-type", "x-requested-with", "x-aj-referer")
}
page_headers.update({
page_headers.update(
{
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"referer": "https://fragment.com/",
"sec-fetch-dest": "document",
"sec-fetch-mode": "navigate",
"upgrade-insecure-requests": "1",
})
}
)
async with httpx.AsyncClient(cookies=cookies) as client:
response = await client.get(page_url, headers=page_headers)
+13 -6
View File
@@ -1,9 +1,11 @@
import asyncio
import logging
from tonutils.clients import ToncenterClient
from tonutils.contracts.wallet import WalletV5R1
from tonutils.clients import TonapiClient
from tonutils.types import NetworkGlobalID
from app.core import config
from app.core.constants import WALLET_CLASSES
from app.core.exceptions import TransactionError, WalletError
from app.utils.decoder import clean_decode
@@ -19,8 +21,10 @@ async def process_transaction(transaction_data: dict) -> str:
"The API response is missing expected 'transaction.messages' data."
)
client = ToncenterClient(api_key=config.API_KEY)
wallet, _, _, _ = WalletV5R1.from_mnemonic(client=client, mnemonic=config.SEED)
client = TonapiClient(network=NetworkGlobalID.MAINNET, api_key=config.API_KEY)
async with client:
wallet_cls = WALLET_CLASSES[config.WALLET_VERSION]
wallet, _, _, _ = wallet_cls.from_mnemonic(client=client, mnemonic=config.SEED)
# Check balance before broadcasting
try:
@@ -40,11 +44,14 @@ async def process_transaction(transaction_data: dict) -> str:
message = transaction_data["transaction"]["messages"][0]
payload = clean_decode(message["payload"])
return await wallet.transfer(
await wallet.refresh()
result = await wallet.transfer(
destination=message["address"],
amount=int(message["amount"]) / 1_000_000_000,
amount=int(message["amount"]), # nanotons, not TON
body=payload,
)
return result
except (WalletError, TransactionError):
raise
except Exception as exc:
+66 -7
View File
@@ -1,24 +1,83 @@
import asyncio
import base64
import json
import logging
from typing import Any
import httpx
from tonutils.clients import ToncenterClient
from tonutils.contracts.wallet import WalletV5R1
from tonutils.clients import TonapiClient
from tonutils.types import NetworkGlobalID
from app.core import config
from app.core.constants import DEVICE
from app.core.constants import DEVICE, WALLET_CLASSES
from app.core.exceptions import TransactionError, WalletError
from app.utils.transaction import process_transaction
from app.utils.decoder import clean_decode
logger = logging.getLogger(__name__)
async def get_account_info() -> dict[str, Any]:
def initialize_ton_client() -> TonapiClient:
return TonapiClient(network=NetworkGlobalID.MAINNET, api_key=config.API_KEY)
async def process_transaction(transaction_data: dict) -> str:
logger.debug("transaction_data: %s", transaction_data)
if "transaction" not in transaction_data or "messages" not in transaction_data["transaction"]:
raise TransactionError(
"Fragment returned an invalid transaction payload. "
"The API response is missing expected 'transaction.messages' data."
)
async with initialize_ton_client() as client:
wallet_cls = WALLET_CLASSES[config.WALLET_VERSION]
wallet, _, _, _ = wallet_cls.from_mnemonic(client=client, mnemonic=config.SEED)
# Check balance before broadcasting
# try:
# await wallet.refresh()
# balance_ton = wallet.balance / 1_000_000_000
# if balance_ton < 0.056:
# raise WalletError(
# f"TON wallet balance is too low: {balance_ton:.2f} TON. "
# "Minimum required is 0.056 TON."
# )
# except WalletError:
# raise
# except Exception as exc:
# raise WalletError(f"Wallet balance check failed: {exc}") from exc
try:
client = ToncenterClient(api_key=config.API_KEY)
wallet, pub_key, _, _ = WalletV5R1.from_mnemonic(client=client, mnemonic=config.SEED)
message = transaction_data["transaction"]["messages"][0]
payload = clean_decode(message["payload"])
seqno_before = wallet.seqno
result = await wallet.transfer(
destination=message["address"],
amount=int(message["amount"]), # nanotons, not TON
body=payload,
)
# Wait for on-chain confirmation so the next call sees updated seqno
for _ in range(30):
await asyncio.sleep(3)
await wallet.refresh()
if wallet.seqno != seqno_before:
break
return result
except (WalletError, TransactionError):
raise
except Exception as exc:
raise TransactionError(f"Transaction broadcast failed: {exc}") from exc
async def get_account_info() -> dict[str, Any]:
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),
+6
View File
@@ -0,0 +1,6 @@
{
"stel_ssid": "",
"stel_dt": "",
"stel_token": "",
"stel_ton_token": ""
}
+3 -1
View File
@@ -29,7 +29,9 @@ async def buy_premium_example():
if result["success"]:
data = result["data"]
logger.info(f"Premium purchase successful: {data['months']} months sent to {data['username']}")
logger.info(
f"Premium purchase successful: {data['months']} months sent to {data['username']}"
)
logger.info(f"Transaction ID: {data['transaction_id']}")
else:
logger.error(f"Premium purchase failed: {result['error']}")
+1
View File
@@ -1,5 +1,6 @@
"""Tests for clean_decode() — BOC-encoded Fragment payloads decode to
human-readable UTF-8 with the Telegram label and Ref# intact."""
import re
import pytest
+1
View File
@@ -1,5 +1,6 @@
"""Tests for get_fragment_hash() — fetches a valid lowercase hex hash
from the fragment.com/stars/buy page source."""
import re
import pytest