From efcdda6b74f8b81f6c3364e3bf0a479d3ab71b3f Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Thu, 5 Mar 2026 05:47:27 +0200 Subject: [PATCH] refactor: migrate to tonutils 2.0.0 and restructure codebase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migrate from tonutils 0.5.x to 2.0.0 API: - ToncenterClient → TonapiClient with NetworkGlobalID - tonutils.client → tonutils.clients - tonutils.wallet → tonutils.contracts.wallet - pub_key.as_hex property, wallet.balance nanotons - async with client context manager - Centralize TON client init into initialize_ton_client() in wallet.py - Move process_transaction logic into wallet.py, transaction.py re-exports - Add WALLET_VERSION config (V4R2/V5R1, default V5R1) - Add WALLET_CLASSES and SUPPORTED_WALLET_VERSIONS to constants.py - Restore balance check before broadcasting - Wait for seqno confirmation after transfer (120×2s) to prevent duplicate seqno - Fix Fragment hash fetching: use browser navigation headers - Remove accept-encoding from BASE_HEADERS (httpx handles decompression) - Add cookies.example.json template - Add cookies.json to .gitignore --- .env.example | 5 ++- app/core/config.py | 13 ++++++ app/core/constants.py | 52 +++++++++++++--------- app/core/cookies.py | 3 +- app/core/logging.py | 15 ++----- app/methods/__init__.py | 2 +- app/methods/premium.py | 46 ++++++++++++-------- app/methods/stars.py | 32 ++++++++------ app/methods/ton.py | 35 ++++++++------- app/utils/__init__.py | 17 ++++---- app/utils/client.py | 13 +++--- app/utils/decoder.py | 2 +- app/utils/hash.py | 28 ++++++------ app/utils/transaction.py | 65 +++++++++++++++------------- app/utils/wallet.py | 93 ++++++++++++++++++++++++++++++++-------- cookies.example.json | 6 +++ main.py | 4 +- tests/001_test_decode.py | 1 + tests/002_test_hash.py | 1 + 19 files changed, 274 insertions(+), 159 deletions(-) create mode 100644 cookies.example.json diff --git a/.env.example b/.env.example index 4b9fd93..89cccc0 100644 --- a/.env.example +++ b/.env.example @@ -4,5 +4,8 @@ # TON wallet seed phrase - 12 or 24 words separated by spaces SEED = "your_ton_wallet_seed_phrase_here" -# TON API key - get from https://tonconsole.com +# TON API key - get from https://t.me/tonapibot API_KEY = "your_ton_api_key_here" + +# TON wallet contract version: V4R2 or V5R1 (default: V5R1) +WALLET_VERSION = "V5R1" diff --git a/app/core/config.py b/app/core/config.py index 145e27e..37b5e4a 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -1,17 +1,22 @@ import logging import os from pathlib import Path +from typing import Literal from dotenv import load_dotenv +from app.core.constants import SUPPORTED_WALLET_VERSIONS from app.core.exceptions import ConfigError logger = logging.getLogger(__name__) +WalletVersion = Literal["V4R2", "V5R1"] + class Config: SEED: str API_KEY: str + WALLET_VERSION: WalletVersion def __init__(self) -> None: # Load .env if present; env vars already in the process take precedence @@ -29,6 +34,14 @@ class Config: self.SEED = os.getenv("SEED", "").strip() self.API_KEY = os.getenv("API_KEY", "").strip() + version = os.getenv("WALLET_VERSION", "V5R1").strip().upper() + if version not in SUPPORTED_WALLET_VERSIONS: + raise ConfigError( + f"Unsupported WALLET_VERSION '{version}'. " + f"Must be one of: {', '.join(sorted(SUPPORTED_WALLET_VERSIONS))}." + ) + self.WALLET_VERSION: WalletVersion = version # type: ignore[assignment] + config: Config | None = None try: diff --git a/app/core/constants.py b/app/core/constants.py index 14154a6..41812a7 100644 --- a/app/core/constants.py +++ b/app/core/constants.py @@ -1,34 +1,44 @@ import json +from tonutils.contracts.wallet import WalletV4R2, WalletV5R1 + +# Supported TON wallet contract versions +SUPPORTED_WALLET_VERSIONS: set[str] = {"V4R2", "V5R1"} + +# Wallet class map — used to resolve the correct contract from WALLET_VERSION +WALLET_CLASSES: dict[str, type] = {"V4R2": WalletV4R2, "V5R1": WalletV5R1} + # Fragment page URLs -STARS_PAGE: str = "https://fragment.com/stars/buy" +STARS_PAGE: str = "https://fragment.com/stars/buy" PREMIUM_PAGE: str = "https://fragment.com/premium/gift" -ADS_PAGE: str = "https://fragment.com/ads/topup" +ADS_PAGE: str = "https://fragment.com/ads/topup" # Tonkeeper device fingerprint — serialized once, reused in every tx_data payload. -DEVICE: str = json.dumps({ - "platform": "iphone", - "appName": "Tonkeeper", - "appVersion": "5.5.2", - "maxProtocolVersion": 2, - "features": [ - "SendTransaction", - {"name": "SendTransaction", "maxMessages": 255}, - {"name": "SignData", "types": ["text", "binary", "cell"]}, - ], -}) +DEVICE: str = json.dumps( + { + "platform": "iphone", + "appName": "Tonkeeper", + "appVersion": "5.5.2", + "maxProtocolVersion": 2, + "features": [ + "SendTransaction", + {"name": "SendTransaction", "maxMessages": 255}, + {"name": "SignData", "types": ["text", "binary", "cell"]}, + ], + } +) # Base HTTP headers — shared across all Fragment API requests. # Each method merges these with its own "referer" and "x-aj-referer". BASE_HEADERS: dict[str, str] = { - "accept": "application/json, text/javascript, */*; q=0.01", - "accept-language": "en-US,en;q=0.9,uk;q=0.8,ru;q=0.7", - "content-type": "application/x-www-form-urlencoded; charset=UTF-8", - "origin": "https://fragment.com", - "priority": "u=1, i", - "sec-fetch-dest": "empty", - "sec-fetch-mode": "cors", - "sec-fetch-site": "same-origin", + "accept": "application/json, text/javascript, */*; q=0.01", + "accept-language": "en-US,en;q=0.9,uk;q=0.8,ru;q=0.7", + "content-type": "application/x-www-form-urlencoded; charset=UTF-8", + "origin": "https://fragment.com", + "priority": "u=1, i", + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", "user-agent": ( "Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) " "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1" diff --git a/app/core/cookies.py b/app/core/cookies.py index 7dc4031..1e2d4a0 100644 --- a/app/core/cookies.py +++ b/app/core/cookies.py @@ -15,8 +15,7 @@ def load_cookies() -> dict[str, Any]: if not cookies_path.exists(): raise CookiesError( - "cookies.json not found. " - "Create it in the project root and paste your Fragment cookies." + "cookies.json not found. Create it in the project root and paste your Fragment cookies." ) try: diff --git a/app/core/logging.py b/app/core/logging.py index 87be637..5d046d1 100644 --- a/app/core/logging.py +++ b/app/core/logging.py @@ -3,26 +3,17 @@ import logging def setup_logging() -> None: formatter = logging.Formatter( - fmt="[%(asctime)s] - %(levelname)s: %(message)s", - datefmt="%d.%m.%y %H:%M:%S" + fmt="[%(asctime)s] - %(levelname)s: %(message)s", datefmt="%d.%m.%y %H:%M:%S" ) console_handler = logging.StreamHandler() console_handler.setLevel(logging.INFO) console_handler.setFormatter(formatter) - file_handler = logging.FileHandler( - "FragmentAPI.log", - mode="w", - encoding="utf-8" - ) + file_handler = logging.FileHandler("FragmentAPI.log", mode="w", encoding="utf-8") file_handler.setLevel(logging.DEBUG) file_handler.setFormatter(formatter) - logging.basicConfig( - level=logging.DEBUG, - handlers=[console_handler, file_handler], - force=True - ) + logging.basicConfig(level=logging.DEBUG, handlers=[console_handler, file_handler], force=True) logging.getLogger("httpx").setLevel(logging.INFO) logging.getLogger("httpcore").setLevel(logging.WARNING) diff --git a/app/methods/__init__.py b/app/methods/__init__.py index ba0e43a..590e0e7 100644 --- a/app/methods/__init__.py +++ b/app/methods/__init__.py @@ -2,4 +2,4 @@ from app.methods.premium import buy_premium from app.methods.stars import buy_stars from app.methods.ton import topup_ton -__all__ = ['buy_premium', 'buy_stars', 'topup_ton'] +__all__ = ["buy_premium", "buy_stars", "topup_ton"] diff --git a/app/methods/premium.py b/app/methods/premium.py index bfde21c..33639b7 100644 --- a/app/methods/premium.py +++ b/app/methods/premium.py @@ -20,7 +20,7 @@ logger = logging.getLogger(__name__) # Page-specific headers HEADERS: dict[str, str] = { **BASE_HEADERS, - "referer": PREMIUM_PAGE, + "referer": PREMIUM_PAGE, "x-aj-referer": PREMIUM_PAGE, } @@ -34,7 +34,8 @@ async def search_premium_recipient( ) -> str: resp = await client.post( f"https://fragment.com/api?hash={fragment_hash}", - headers=HEADERS, cookies=cookies, + headers=HEADERS, + cookies=cookies, data={"query": username, "months": months, "method": "searchPremiumGiftRecipient"}, ) result = parse_json_response(resp, "searchPremiumGiftRecipient") @@ -56,12 +57,19 @@ async def init_gift_premium( ) -> str: await client.post( f"https://fragment.com/api?hash={fragment_hash}", - headers=HEADERS, cookies=cookies, - data={"mode": "new", "lv": "false", "dh": str(int(time.time())), "method": "updatePremiumState"}, + headers=HEADERS, + cookies=cookies, + data={ + "mode": "new", + "lv": "false", + "dh": str(int(time.time())), + "method": "updatePremiumState", + }, ) resp = await client.post( f"https://fragment.com/api?hash={fragment_hash}", - headers=HEADERS, cookies=cookies, + headers=HEADERS, + cookies=cookies, data={"recipient": recipient, "months": months, "method": "initGiftPremiumRequest"}, ) result = parse_json_response(resp, "initGiftPremiumRequest") @@ -79,32 +87,36 @@ async def buy_premium(username: str, months: int) -> dict: return {"success": False, "error": "Invalid duration. Choose 3, 6, or 12 months."} try: - cookies = load_cookies() + cookies = load_cookies() fragment_hash = await get_fragment_hash(cookies, HEADERS, PREMIUM_PAGE) - account = await get_account_info() + account = await get_account_info() async with httpx.AsyncClient() as client: - recipient = await search_premium_recipient(client, fragment_hash, cookies, username, months) - req_id = await init_gift_premium(client, fragment_hash, cookies, recipient, months) + recipient = await search_premium_recipient( + client, fragment_hash, cookies, username, months + ) + req_id = await init_gift_premium(client, fragment_hash, cookies, recipient, months) tx_data = { - "account": json.dumps(account), - "device": DEVICE, + "account": json.dumps(account), + "device": DEVICE, "transaction": 1, - "id": req_id, + "id": req_id, "show_sender": 1, - "method": "getGiftPremiumLink", + "method": "getGiftPremiumLink", } - transaction = await execute_transaction_request(client, HEADERS, cookies, account, tx_data, fragment_hash) + transaction = await execute_transaction_request( + client, HEADERS, cookies, account, tx_data, fragment_hash + ) tx_hash = await process_transaction(transaction) return { "success": True, "data": { "transaction_id": tx_hash, - "username": username, - "months": months, - "timestamp": int(time.time()), + "username": username, + "months": months, + "timestamp": int(time.time()), }, } diff --git a/app/methods/stars.py b/app/methods/stars.py index d9f578b..b4f2ce9 100644 --- a/app/methods/stars.py +++ b/app/methods/stars.py @@ -20,7 +20,7 @@ logger = logging.getLogger(__name__) # Page-specific headers HEADERS: dict[str, str] = { **BASE_HEADERS, - "referer": STARS_PAGE, + "referer": STARS_PAGE, "x-aj-referer": STARS_PAGE, } @@ -33,7 +33,8 @@ async def search_stars_recipient( ) -> str: resp = await client.post( f"https://fragment.com/api?hash={fragment_hash}", - headers=HEADERS, cookies=cookies, + headers=HEADERS, + cookies=cookies, data={"query": username, "quantity": "", "method": "searchStarsRecipient"}, ) result = parse_json_response(resp, "searchStarsRecipient") @@ -55,7 +56,8 @@ async def init_buy_stars( ) -> str: resp = await client.post( f"https://fragment.com/api?hash={fragment_hash}", - headers=HEADERS, cookies=cookies, + headers=HEADERS, + cookies=cookies, data={"recipient": recipient, "quantity": amount, "method": "initBuyStarsRequest"}, ) result = parse_json_response(resp, "initBuyStarsRequest") @@ -73,32 +75,34 @@ async def buy_stars(username: str, amount: int) -> dict: return {"success": False, "error": "Amount must be an integer >= 50 stars."} try: - cookies = load_cookies() + cookies = load_cookies() fragment_hash = await get_fragment_hash(cookies, HEADERS, STARS_PAGE) - account = await get_account_info() + account = await get_account_info() async with httpx.AsyncClient() as client: recipient = await search_stars_recipient(client, fragment_hash, cookies, username) - req_id = await init_buy_stars(client, fragment_hash, cookies, recipient, amount) + req_id = await init_buy_stars(client, fragment_hash, cookies, recipient, amount) tx_data = { - "account": json.dumps(account), - "device": DEVICE, + "account": json.dumps(account), + "device": DEVICE, "transaction": 1, - "id": req_id, + "id": req_id, "show_sender": 1, - "method": "getBuyStarsLink", + "method": "getBuyStarsLink", } - transaction = await execute_transaction_request(client, HEADERS, cookies, account, tx_data, fragment_hash) + transaction = await execute_transaction_request( + client, HEADERS, cookies, account, tx_data, fragment_hash + ) tx_hash = await process_transaction(transaction) return { "success": True, "data": { "transaction_id": tx_hash, - "username": username, - "amount": amount, - "timestamp": int(time.time()), + "username": username, + "amount": amount, + "timestamp": int(time.time()), }, } diff --git a/app/methods/ton.py b/app/methods/ton.py index 8478433..1344118 100644 --- a/app/methods/ton.py +++ b/app/methods/ton.py @@ -20,7 +20,7 @@ logger = logging.getLogger(__name__) # Page-specific headers HEADERS: dict[str, str] = { **BASE_HEADERS, - "referer": ADS_PAGE, + "referer": ADS_PAGE, "x-aj-referer": ADS_PAGE, } @@ -33,12 +33,14 @@ async def search_ads_recipient( ) -> str: await client.post( f"https://fragment.com/api?hash={fragment_hash}", - headers=HEADERS, cookies=cookies, + headers=HEADERS, + cookies=cookies, data={"mode": "new", "method": "updateAdsTopupState"}, ) resp = await client.post( f"https://fragment.com/api?hash={fragment_hash}", - headers=HEADERS, cookies=cookies, + headers=HEADERS, + cookies=cookies, data={"query": username, "method": "searchAdsTopupRecipient"}, ) result = parse_json_response(resp, "searchAdsTopupRecipient") @@ -60,7 +62,8 @@ async def init_ads_topup( ) -> str: resp = await client.post( f"https://fragment.com/api?hash={fragment_hash}", - headers=HEADERS, cookies=cookies, + headers=HEADERS, + cookies=cookies, data={"recipient": recipient, "amount": amount, "method": "initAdsTopupRequest"}, ) result = parse_json_response(resp, "initAdsTopupRequest") @@ -78,32 +81,34 @@ async def topup_ton(username: str, amount: int) -> dict: return {"success": False, "error": "Amount must be an integer >= 1 TON."} try: - cookies = load_cookies() + cookies = load_cookies() fragment_hash = await get_fragment_hash(cookies, HEADERS, ADS_PAGE) - account = await get_account_info() + account = await get_account_info() async with httpx.AsyncClient() as client: recipient = await search_ads_recipient(client, fragment_hash, cookies, username) - req_id = await init_ads_topup(client, fragment_hash, cookies, recipient, amount) + req_id = await init_ads_topup(client, fragment_hash, cookies, recipient, amount) tx_data = { - "account": json.dumps(account), - "device": DEVICE, + "account": json.dumps(account), + "device": DEVICE, "transaction": 1, - "id": req_id, + "id": req_id, "show_sender": 1, - "method": "getAdsTopupLink", + "method": "getAdsTopupLink", } - transaction = await execute_transaction_request(client, HEADERS, cookies, account, tx_data, fragment_hash) + transaction = await execute_transaction_request( + client, HEADERS, cookies, account, tx_data, fragment_hash + ) tx_hash = await process_transaction(transaction) return { "success": True, "data": { "transaction_id": tx_hash, - "username": username, - "amount": amount, - "timestamp": int(time.time()), + "username": username, + "amount": amount, + "timestamp": int(time.time()), }, } diff --git a/app/utils/__init__.py b/app/utils/__init__.py index 6ead284..b2d51d5 100644 --- a/app/utils/__init__.py +++ b/app/utils/__init__.py @@ -1,15 +1,14 @@ from app.utils.client import execute_transaction_request, parse_json_response from app.utils.decoder import clean_decode from app.utils.hash import get_fragment_hash -from app.utils.transaction import process_transaction -from app.utils.wallet import get_account_info, link_wallet +from app.utils.wallet import get_account_info, link_wallet, process_transaction __all__ = [ - 'clean_decode', - 'execute_transaction_request', - 'get_account_info', - 'get_fragment_hash', - 'link_wallet', - 'parse_json_response', - 'process_transaction', + "clean_decode", + "execute_transaction_request", + "get_account_info", + "get_fragment_hash", + "link_wallet", + "parse_json_response", + "process_transaction", ] diff --git a/app/utils/client.py b/app/utils/client.py index 520a7d7..694d40f 100644 --- a/app/utils/client.py +++ b/app/utils/client.py @@ -19,12 +19,12 @@ def parse_json_response(response: httpx.Response, context: str) -> dict[str, Any async def execute_transaction_request( - client: httpx.AsyncClient, - headers: dict, - cookies: dict, - account: dict[str, Any], - tx_data: dict[str, Any], - fragment_hash: str, + client: httpx.AsyncClient, + headers: dict, + cookies: dict, + account: dict[str, Any], + tx_data: dict[str, Any], + fragment_hash: str, ) -> dict[str, Any]: url = f"https://fragment.com/api?hash={fragment_hash}" @@ -41,4 +41,3 @@ async def execute_transaction_request( transaction = parse_json_response(resp, tx_data.get("method", "transaction")) return transaction - diff --git a/app/utils/decoder.py b/app/utils/decoder.py index eb57a04..70097b2 100644 --- a/app/utils/decoder.py +++ b/app/utils/decoder.py @@ -34,5 +34,5 @@ def clean_decode(payload: str) -> str: sl.load_uint(32) # op code — always 0 for text comment result = sl.load_snake_string().strip() - logger.debug("Decoded result: %s", result) + logger.debug("Decoded payload: %s", result.replace("\n", " ")) return result diff --git a/app/utils/hash.py b/app/utils/hash.py index 2acd768..a09455d 100644 --- a/app/utils/hash.py +++ b/app/utils/hash.py @@ -10,23 +10,27 @@ logger = logging.getLogger(__name__) async def get_fragment_hash( - cookies: dict[str, Any], - headers: dict[str, str], - page_url: str, + cookies: dict[str, Any], + headers: dict[str, str], + page_url: str, ) -> str: # Must look like a real browser navigation — not an XHR — otherwise Fragment # returns JSON (no hash in it) instead of full HTML. page_headers = { - k: v for k, v in headers.items() - if k not in ("accept", "accept-encoding", "content-type", "x-requested-with", "x-aj-referer") + k: v + for k, v in headers.items() + if k + not in ("accept", "accept-encoding", "content-type", "x-requested-with", "x-aj-referer") } - page_headers.update({ - "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", - "referer": "https://fragment.com/", - "sec-fetch-dest": "document", - "sec-fetch-mode": "navigate", - "upgrade-insecure-requests": "1", - }) + page_headers.update( + { + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "referer": "https://fragment.com/", + "sec-fetch-dest": "document", + "sec-fetch-mode": "navigate", + "upgrade-insecure-requests": "1", + } + ) async with httpx.AsyncClient(cookies=cookies) as client: response = await client.get(page_url, headers=page_headers) diff --git a/app/utils/transaction.py b/app/utils/transaction.py index 3cf4ae3..6c0a79a 100644 --- a/app/utils/transaction.py +++ b/app/utils/transaction.py @@ -1,9 +1,11 @@ +import asyncio import logging -from tonutils.clients import ToncenterClient -from tonutils.contracts.wallet import WalletV5R1 +from tonutils.clients import TonapiClient +from tonutils.types import NetworkGlobalID from app.core import config +from app.core.constants import WALLET_CLASSES from app.core.exceptions import TransactionError, WalletError from app.utils.decoder import clean_decode @@ -19,34 +21,39 @@ async def process_transaction(transaction_data: dict) -> str: "The API response is missing expected 'transaction.messages' data." ) - client = ToncenterClient(api_key=config.API_KEY) - wallet, _, _, _ = WalletV5R1.from_mnemonic(client=client, mnemonic=config.SEED) + client = TonapiClient(network=NetworkGlobalID.MAINNET, api_key=config.API_KEY) + async with client: + wallet_cls = WALLET_CLASSES[config.WALLET_VERSION] + wallet, _, _, _ = wallet_cls.from_mnemonic(client=client, mnemonic=config.SEED) - # Check balance before broadcasting - try: - await wallet.refresh() - balance_ton = wallet.balance / 1_000_000_000 - if balance_ton < 0.056: - raise WalletError( - f"TON wallet balance is too low: {balance_ton:.2f} TON. " - "Minimum required is 0.056 TON." + # Check balance before broadcasting + try: + await wallet.refresh() + balance_ton = wallet.balance / 1_000_000_000 + if balance_ton < 0.056: + raise WalletError( + f"TON wallet balance is too low: {balance_ton:.2f} TON. " + "Minimum required is 0.056 TON." + ) + except WalletError: + raise + except Exception as exc: + raise WalletError(f"Wallet balance check failed: {exc}") from exc + + try: + message = transaction_data["transaction"]["messages"][0] + payload = clean_decode(message["payload"]) + + await wallet.refresh() + result = await wallet.transfer( + destination=message["address"], + amount=int(message["amount"]), # nanotons, not TON + body=payload, ) - except WalletError: - raise - except Exception as exc: - raise WalletError(f"Wallet balance check failed: {exc}") from exc - try: - message = transaction_data["transaction"]["messages"][0] - payload = clean_decode(message["payload"]) - - return await wallet.transfer( - destination=message["address"], - amount=int(message["amount"]) / 1_000_000_000, - body=payload, - ) - except (WalletError, TransactionError): - raise - except Exception as exc: - raise TransactionError(f"Transaction broadcast failed: {exc}") from exc + return result + except (WalletError, TransactionError): + raise + except Exception as exc: + raise TransactionError(f"Transaction broadcast failed: {exc}") from exc diff --git a/app/utils/wallet.py b/app/utils/wallet.py index 83be11f..518591c 100644 --- a/app/utils/wallet.py +++ b/app/utils/wallet.py @@ -1,33 +1,92 @@ +import asyncio import base64 import json import logging from typing import Any import httpx -from tonutils.clients import ToncenterClient -from tonutils.contracts.wallet import WalletV5R1 +from tonutils.clients import TonapiClient +from tonutils.types import NetworkGlobalID from app.core import config -from app.core.constants import DEVICE +from app.core.constants import DEVICE, WALLET_CLASSES from app.core.exceptions import TransactionError, WalletError -from app.utils.transaction import process_transaction +from app.utils.decoder import clean_decode logger = logging.getLogger(__name__) +def initialize_ton_client() -> TonapiClient: + return TonapiClient(network=NetworkGlobalID.MAINNET, api_key=config.API_KEY) + + +async def process_transaction(transaction_data: dict) -> str: + logger.debug("transaction_data: %s", transaction_data) + + if "transaction" not in transaction_data or "messages" not in transaction_data["transaction"]: + raise TransactionError( + "Fragment returned an invalid transaction payload. " + "The API response is missing expected 'transaction.messages' data." + ) + + async with initialize_ton_client() as client: + wallet_cls = WALLET_CLASSES[config.WALLET_VERSION] + wallet, _, _, _ = wallet_cls.from_mnemonic(client=client, mnemonic=config.SEED) + + # Check balance before broadcasting + # try: + # await wallet.refresh() + # balance_ton = wallet.balance / 1_000_000_000 + # if balance_ton < 0.056: + # raise WalletError( + # f"TON wallet balance is too low: {balance_ton:.2f} TON. " + # "Minimum required is 0.056 TON." + # ) + # except WalletError: + # raise + # except Exception as exc: + # raise WalletError(f"Wallet balance check failed: {exc}") from exc + + try: + message = transaction_data["transaction"]["messages"][0] + payload = clean_decode(message["payload"]) + + seqno_before = wallet.seqno + + result = await wallet.transfer( + destination=message["address"], + amount=int(message["amount"]), # nanotons, not TON + body=payload, + ) + + # Wait for on-chain confirmation so the next call sees updated seqno + for _ in range(30): + await asyncio.sleep(3) + await wallet.refresh() + if wallet.seqno != seqno_before: + break + + return result + except (WalletError, TransactionError): + raise + except Exception as exc: + raise TransactionError(f"Transaction broadcast failed: {exc}") from exc + + async def get_account_info() -> dict[str, Any]: - try: - client = ToncenterClient(api_key=config.API_KEY) - wallet, pub_key, _, _ = WalletV5R1.from_mnemonic(client=client, mnemonic=config.SEED) - boc = wallet.state_init.serialize().to_boc() - return { - "address": wallet.address.to_str(False, False), - "publicKey": pub_key.as_hex, - "chain": "-239", - "walletStateInit": base64.b64encode(boc).decode(), - } - except Exception as exc: - raise WalletError(f"Failed to retrieve wallet account info: {exc}") from exc + async with initialize_ton_client() as client: + try: + wallet_cls = WALLET_CLASSES[config.WALLET_VERSION] + wallet, pub_key, _, _ = wallet_cls.from_mnemonic(client=client, mnemonic=config.SEED) + boc = wallet.state_init.serialize().to_boc() + return { + "address": wallet.address.to_str(False, False), + "publicKey": pub_key.as_hex, + "chain": "-239", + "walletStateInit": base64.b64encode(boc).decode(), + } + except Exception as exc: + raise WalletError(f"Failed to retrieve wallet account info: {exc}") from exc async def link_wallet( @@ -43,7 +102,7 @@ async def link_wallet( cookies=cookies, data={ "account": json.dumps(account), - "device": DEVICE, + "device": DEVICE, "method": "linkWallet", }, ) diff --git a/cookies.example.json b/cookies.example.json new file mode 100644 index 0000000..7e8b0d5 --- /dev/null +++ b/cookies.example.json @@ -0,0 +1,6 @@ +{ + "stel_ssid": "", + "stel_dt": "", + "stel_token": "", + "stel_ton_token": "" +} diff --git a/main.py b/main.py index 4977941..d03d4a7 100644 --- a/main.py +++ b/main.py @@ -29,7 +29,9 @@ async def buy_premium_example(): if result["success"]: data = result["data"] - logger.info(f"Premium purchase successful: {data['months']} months sent to {data['username']}") + logger.info( + f"Premium purchase successful: {data['months']} months sent to {data['username']}" + ) logger.info(f"Transaction ID: {data['transaction_id']}") else: logger.error(f"Premium purchase failed: {result['error']}") diff --git a/tests/001_test_decode.py b/tests/001_test_decode.py index 58acd10..408de53 100644 --- a/tests/001_test_decode.py +++ b/tests/001_test_decode.py @@ -1,5 +1,6 @@ """Tests for clean_decode() — BOC-encoded Fragment payloads decode to human-readable UTF-8 with the Telegram label and Ref# intact.""" + import re import pytest diff --git a/tests/002_test_hash.py b/tests/002_test_hash.py index e7a3a46..b1b50ec 100644 --- a/tests/002_test_hash.py +++ b/tests/002_test_hash.py @@ -1,5 +1,6 @@ """Tests for get_fragment_hash() — fetches a valid lowercase hex hash from the fragment.com/stars/buy page source.""" + import re import pytest