mirror of
https://github.com/bohd4nx/FragmentAPI.git
synced 2026-07-25 06:14:29 +00:00
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:
+4
-1
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -41,4 +41,3 @@ async def execute_transaction_request(
|
||||
transaction = parse_json_response(resp, tx_data.get("method", "transaction"))
|
||||
|
||||
return transaction
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"stel_ssid": "",
|
||||
"stel_dt": "",
|
||||
"stel_token": "",
|
||||
"stel_ton_token": ""
|
||||
}
|
||||
@@ -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,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,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
|
||||
|
||||
Reference in New Issue
Block a user