mirror of
https://github.com/vibe-existing/pyfragment.git
synced 2026-07-25 06:54:31 +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:
|
||||
|
||||
+31
-21
@@ -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"
|
||||
|
||||
+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"]
|
||||
|
||||
+29
-17
@@ -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()),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
+18
-14
@@ -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()),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
+20
-15
@@ -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()),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
+6
-7
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+16
-12
@@ -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)
|
||||
|
||||
+36
-29
@@ -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
|
||||
|
||||
|
||||
+76
-17
@@ -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",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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