",
+ "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 | 1–1,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 | 50–1,000,000 Stars |
-### Username Formats
-
-All methods accept various username formats:
-
-- `@username` (with @)
-- `username` (without @)
+Usernames can be passed with or without `@`.
diff --git a/app/core/logging.py b/app/core/logging.py
index 5d046d1..08c986f 100644
--- a/app/core/logging.py
+++ b/app/core/logging.py
@@ -15,7 +15,7 @@ def setup_logging() -> None:
file_handler.setFormatter(formatter)
logging.basicConfig(level=logging.DEBUG, handlers=[console_handler, file_handler], force=True)
- logging.getLogger("httpx").setLevel(logging.INFO)
+ logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
diff --git a/app/methods/premium.py b/app/methods/premium.py
index 33639b7..a6004f9 100644
--- a/app/methods/premium.py
+++ b/app/methods/premium.py
@@ -87,16 +87,25 @@ async def buy_premium(username: str, months: int) -> dict:
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:
+ logger.info("Searching recipient: %s", username)
recipient = await search_premium_recipient(
client, fragment_hash, cookies, username, months
)
+
+ logger.info("Initializing Premium gift request: %s months to %s", months, username)
req_id = await init_gift_premium(client, fragment_hash, cookies, recipient, months)
+ # logger.info("Requesting transaction payload (req_id=%s)", req_id)
tx_data = {
"account": json.dumps(account),
"device": DEVICE,
@@ -109,6 +118,7 @@ async def buy_premium(username: str, months: int) -> dict:
client, HEADERS, cookies, account, tx_data, fragment_hash
)
+ logger.info("Broadcasting transaction to TON blockchain")
tx_hash = await process_transaction(transaction)
return {
"success": True,
diff --git a/app/methods/stars.py b/app/methods/stars.py
index b4f2ce9..2dbf05e 100644
--- a/app/methods/stars.py
+++ b/app/methods/stars.py
@@ -75,14 +75,23 @@ async def buy_stars(username: str, amount: int) -> dict:
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:
+ logger.info("Searching recipient: %s", username)
recipient = await search_stars_recipient(client, fragment_hash, cookies, username)
+
+ 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": json.dumps(account),
"device": DEVICE,
@@ -95,6 +104,7 @@ async def buy_stars(username: str, amount: int) -> dict:
client, HEADERS, cookies, account, tx_data, fragment_hash
)
+ logger.info("Broadcasting transaction to TON blockchain")
tx_hash = await process_transaction(transaction)
return {
"success": True,
diff --git a/app/methods/ton.py b/app/methods/ton.py
index 1344118..60b3932 100644
--- a/app/methods/ton.py
+++ b/app/methods/ton.py
@@ -81,14 +81,23 @@ async def topup_ton(username: str, amount: int) -> dict:
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:
+ logger.info("Searching recipient: %s", username)
recipient = await search_ads_recipient(client, fragment_hash, cookies, username)
+
+ 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": json.dumps(account),
"device": DEVICE,
@@ -101,6 +110,7 @@ async def topup_ton(username: str, amount: int) -> dict:
client, HEADERS, cookies, account, tx_data, fragment_hash
)
+ logger.info("Broadcasting transaction to TON blockchain")
tx_hash = await process_transaction(transaction)
return {
"success": True,
diff --git a/app/utils/transaction.py b/app/utils/transaction.py
deleted file mode 100644
index 6c0a79a..0000000
--- a/app/utils/transaction.py
+++ /dev/null
@@ -1,59 +0,0 @@
-import asyncio
-import logging
-
-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
-
-logger = logging.getLogger(__name__)
-
-
-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."
- )
-
- 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."
- )
- 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,
- )
-
- 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 518591c..01932cd 100644
--- a/app/utils/wallet.py
+++ b/app/utils/wallet.py
@@ -1,4 +1,3 @@
-import asyncio
import base64
import json
import logging
@@ -29,43 +28,37 @@ async def process_transaction(transaction_data: dict) -> str:
"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:
+ 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
diff --git a/tests/003_test_integration.py b/tests/003_test_integration.py
new file mode 100644
index 0000000..8736906
--- /dev/null
+++ b/tests/003_test_integration.py
@@ -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"]