Refactor wallet transaction handling and introduce tonapi module

- Removed wallet transaction and transfer logic from the wallet domain.
- Introduced a new tonapi module to handle transactions and balance checks.
- Updated tests to reflect the new structure and ensure functionality remains intact.
- Added functionality for sending TON and USDT transfers through the tonapi module.
- Improved error handling and validation for payment balances.
- Cleaned up and organized imports across the codebase.
This commit is contained in:
bohd4nx
2026-05-20 23:42:24 +03:00
parent ae164d4fe7
commit 01a5befd87
38 changed files with 137 additions and 158 deletions
-5
View File
@@ -1,8 +1,3 @@
# Copyright (c) 2026 bohd4nx
#
# This source code is licensed under the MIT License found in the
# LICENSE file in the root directory of this source tree.
from importlib.metadata import version from importlib.metadata import version
from pyfragment.client import FragmentClient from pyfragment.client import FragmentClient
+4 -5
View File
@@ -12,8 +12,7 @@ from pyfragment.domains.anonymous_numbers.service import AnonymousNumbersService
from pyfragment.domains.giveaways.service import GiveawaysService from pyfragment.domains.giveaways.service import GiveawaysService
from pyfragment.domains.marketplace.service import MarketplaceService from pyfragment.domains.marketplace.service import MarketplaceService
from pyfragment.domains.purchases.service import PurchasesService from pyfragment.domains.purchases.service import PurchasesService
from pyfragment.domains.wallet.info import get_wallet_info from pyfragment.domains.tonapi.service import TonapiService
from pyfragment.domains.wallet.service import WalletService
from pyfragment.exceptions import ConfigurationError, CookieError from pyfragment.exceptions import ConfigurationError, CookieError
from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult
from pyfragment.models.enums import PaymentMethod, WalletVersion from pyfragment.models.enums import PaymentMethod, WalletVersion
@@ -99,7 +98,7 @@ class FragmentClient:
self.marketplace = MarketplaceService(self) self.marketplace = MarketplaceService(self)
self.purchases = PurchasesService(self) self.purchases = PurchasesService(self)
self.giveaways = GiveawaysService(self) self.giveaways = GiveawaysService(self)
self.wallet = WalletService(self) self.tonapi = TonapiService(self)
self.anonymous_numbers = AnonymousNumbersService(self) self.anonymous_numbers = AnonymousNumbersService(self)
self.ads = AdsService(self) self.ads = AdsService(self)
@@ -163,7 +162,7 @@ class FragmentClient:
Returns: Returns:
:class:`AdsTopupResult` with ``transaction_id``, ``username``, and ``amount``. :class:`AdsTopupResult` with ``transaction_id``, ``username``, and ``amount``.
""" """
return await self.wallet.topup_ton(username, amount, show_sender=show_sender) return await self.ads.topup_ton(username, amount, show_sender=show_sender)
async def recharge_ads(self, account: str, amount: int) -> AdsRechargeResult: async def recharge_ads(self, account: str, amount: int) -> AdsRechargeResult:
"""Add funds to your own Telegram Ads account. """Add funds to your own Telegram Ads account.
@@ -186,7 +185,7 @@ class FragmentClient:
(``"active"``, ``"uninit"``, ``"nonexist"``, or ``"frozen"``), (``"active"``, ``"uninit"``, ``"nonexist"``, or ``"frozen"``),
``ton_balance`` in TON, and ``usdt_balance`` in USDT. ``ton_balance`` in TON, and ``usdt_balance`` in USDT.
""" """
return await get_wallet_info(self) return await self.tonapi.get_wallet()
async def giveaway_stars( async def giveaway_stars(
self, self,
-1
View File
@@ -1 +0,0 @@
"""Low-level transport and shared helpers for pyfragment."""
-1
View File
@@ -1 +0,0 @@
"""Domain service package for pyfragment."""
-1
View File
@@ -1 +0,0 @@
"""Ads domain services."""
+2 -2
View File
@@ -4,8 +4,8 @@ import json
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE
from pyfragment.domains.wallet.info import get_account_info from pyfragment.domains.tonapi.info import get_account_info
from pyfragment.domains.wallet.transaction import process_transaction from pyfragment.domains.tonapi.transaction import process_transaction
from pyfragment.exceptions import ConfigurationError, FragmentAPIError, FragmentError, UnexpectedError, VerificationError from pyfragment.exceptions import ConfigurationError, FragmentAPIError, FragmentError, UnexpectedError, VerificationError
from pyfragment.models.payments import AdsRechargeResult from pyfragment.models.payments import AdsRechargeResult
+5 -1
View File
@@ -3,8 +3,9 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from pyfragment.domains.ads.recharge import recharge_ads from pyfragment.domains.ads.recharge import recharge_ads
from pyfragment.domains.ads.tonup import topup_ton
from pyfragment.domains.base import BaseService from pyfragment.domains.base import BaseService
from pyfragment.models.payments import AdsRechargeResult from pyfragment.models.payments import AdsRechargeResult, AdsTopupResult
if TYPE_CHECKING: if TYPE_CHECKING:
pass pass
@@ -13,3 +14,6 @@ if TYPE_CHECKING:
class AdsService(BaseService): class AdsService(BaseService):
async def recharge_ads(self, account: str, amount: int) -> AdsRechargeResult: async def recharge_ads(self, account: str, amount: int) -> AdsRechargeResult:
return await recharge_ads(self._client, account, amount) return await recharge_ads(self._client, account, amount)
async def topup_ton(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
return await topup_ton(self._client, username, amount, show_sender=show_sender)
@@ -4,8 +4,9 @@ import json
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE
from pyfragment.domains.wallet.info import get_account_info from pyfragment.domains.payments import parse_required_payment_amount
from pyfragment.domains.wallet.transaction import process_transaction from pyfragment.domains.tonapi.info import get_account_info
from pyfragment.domains.tonapi.transaction import process_transaction
from pyfragment.exceptions import ( from pyfragment.exceptions import (
ConfigurationError, ConfigurationError,
FragmentAPIError, FragmentAPIError,
@@ -33,6 +34,7 @@ async def topup_ton(client: FragmentClient, username: str, amount: int, show_sen
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username)) raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
result = await client.call("initAdsTopupRequest", {"recipient": recipient, "amount": amount}, page_url=ADS_TOPUP_PAGE) result = await client.call("initAdsTopupRequest", {"recipient": recipient, "amount": amount}, page_url=ADS_TOPUP_PAGE)
required_payment_amount = parse_required_payment_amount(result)
req_id = result.get("req_id") req_id = result.get("req_id")
if not req_id: if not req_id:
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="TON topup")) raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="TON topup"))
@@ -52,7 +54,7 @@ async def topup_ton(client: FragmentClient, username: str, amount: int, show_sen
if transaction.get("need_verify"): if transaction.get("need_verify"):
raise VerificationError(VerificationError.KYC_REQUIRED) raise VerificationError(VerificationError.KYC_REQUIRED)
tx_hash = await process_transaction(client, transaction) tx_hash = await process_transaction(client, transaction, required_payment_amount=required_payment_amount)
return AdsTopupResult(transaction_id=tx_hash, username=username, amount=amount) return AdsTopupResult(transaction_id=tx_hash, username=username, amount=amount)
except FragmentError: except FragmentError:
@@ -1 +0,0 @@
"""Anonymous numbers domain services."""
-1
View File
@@ -1 +0,0 @@
"""Giveaways domain services."""
+2 -2
View File
@@ -5,8 +5,8 @@ from typing import TYPE_CHECKING, get_args
from pyfragment.core.constants import DEVICE, PREMIUM_GIVEAWAY_PAGE, STARS_GIVEAWAY_PAGE from pyfragment.core.constants import DEVICE, PREMIUM_GIVEAWAY_PAGE, STARS_GIVEAWAY_PAGE
from pyfragment.domains.payments import parse_required_payment_amount from pyfragment.domains.payments import parse_required_payment_amount
from pyfragment.domains.wallet.info import get_account_info from pyfragment.domains.tonapi.info import get_account_info
from pyfragment.domains.wallet.transaction import process_transaction from pyfragment.domains.tonapi.transaction import process_transaction
from pyfragment.exceptions import ( from pyfragment.exceptions import (
ConfigurationError, ConfigurationError,
FragmentAPIError, FragmentAPIError,
@@ -1 +0,0 @@
"""Marketplace domain services."""
-1
View File
@@ -1 +0,0 @@
"""Purchases domain services."""
+2 -2
View File
@@ -6,8 +6,8 @@ from typing import TYPE_CHECKING, get_args
from pyfragment.core.constants import DEVICE, PREMIUM_PAGE, STARS_PAGE from pyfragment.core.constants import DEVICE, PREMIUM_PAGE, STARS_PAGE
from pyfragment.domains.payments import parse_required_payment_amount from pyfragment.domains.payments import parse_required_payment_amount
from pyfragment.domains.wallet.info import get_account_info from pyfragment.domains.tonapi.info import get_account_info
from pyfragment.domains.wallet.transaction import process_transaction from pyfragment.domains.tonapi.transaction import process_transaction
from pyfragment.exceptions import ( from pyfragment.exceptions import (
ConfigurationError, ConfigurationError,
FragmentAPIError, FragmentAPIError,
@@ -10,7 +10,15 @@ from pyfragment.exceptions import WalletError
async def get_usdt_balance(ton: Any, wallet_address: str) -> float: async def get_usdt_balance(ton: Any, wallet_address: str) -> float:
"""Return wallet USDT balance via tonutils jetton get-methods.""" """Return the USDT balance for a Fragment-linked TON wallet.
Args:
ton: Active `TonapiClient` instance used to query the TON network.
wallet_address: Raw wallet address that owns the USDT jetton wallet.
Returns:
Wallet balance in USDT as a floating-point value.
"""
try: try:
jetton_wallet_address = await get_wallet_address_get_method( jetton_wallet_address = await get_wallet_address_get_method(
client=ton, client=ton,
@@ -21,7 +29,7 @@ async def get_usdt_balance(ton: Any, wallet_address: str) -> float:
raw_balance = int(wallet_data[0]) if wallet_data else 0 raw_balance = int(wallet_data[0]) if wallet_data else 0
return float(raw_balance) / 1_000_000.0 return float(raw_balance) / 1_000_000.0
except ProviderResponseError as exc: except ProviderResponseError as exc:
# No jetton wallet deployed yet -> effectively zero USDT balance. # No jetton wallet deployed yet means the balance is effectively zero.
if exc.code == 404: if exc.code == 404:
return 0.0 return 0.0
raise WalletError(WalletError.USDT_BALANCE_CHECK_FAILED.format(exc=exc)) from exc raise WalletError(WalletError.USDT_BALANCE_CHECK_FAILED.format(exc=exc)) from exc
@@ -34,7 +42,13 @@ async def check_ton_payment_balance(
amount_ton: float, amount_ton: float,
required_payment_amount: float | None, required_payment_amount: float | None,
) -> None: ) -> None:
"""Validate balance requirements for TON payment method.""" """Validate that the TON wallet can cover a TON-denominated payment.
Args:
balance_ton: Current TON balance in the signing wallet.
amount_ton: Requested payment amount in TON.
required_payment_amount: Fragment-provided minimum amount if the API returned one.
"""
tx_price_ton = amount_ton tx_price_ton = amount_ton
if required_payment_amount is not None and required_payment_amount > 0: if required_payment_amount is not None and required_payment_amount > 0:
tx_price_ton = max(tx_price_ton, required_payment_amount) tx_price_ton = max(tx_price_ton, required_payment_amount)
@@ -55,8 +69,15 @@ async def check_usdt_payment_balance(
ton: Any, ton: Any,
wallet_address: str, wallet_address: str,
) -> None: ) -> None:
"""Validate balance requirements for USDT payment method.""" """Validate that the wallet can cover a USDT-denominated payment.
# USDT payment still needs TON for network fees.
Args:
balance_ton: TON balance used for gas fees.
required_payment_amount: Fragment-provided USDT amount, if available.
ton: Active `TonapiClient` instance used to query jetton balance.
wallet_address: Raw wallet address that owns the USDT jetton wallet.
"""
# USDT payments still need TON for network fees.
if balance_ton < MIN_TON_BALANCE: if balance_ton < MIN_TON_BALANCE:
raise WalletError( raise WalletError(
WalletError.LOW_TON_BALANCE.format( WalletError.LOW_TON_BALANCE.format(
@@ -7,7 +7,7 @@ from ton_core import NetworkGlobalID
from tonutils.clients import TonapiClient from tonutils.clients import TonapiClient
from pyfragment.core.constants import WALLET_CLASSES from pyfragment.core.constants import WALLET_CLASSES
from pyfragment.domains.wallet.balance import get_usdt_balance from pyfragment.domains.tonapi.balance import get_usdt_balance
from pyfragment.exceptions import WalletError from pyfragment.exceptions import WalletError
from pyfragment.models.wallet import WalletInfo from pyfragment.models.wallet import WalletInfo
@@ -16,20 +16,14 @@ if TYPE_CHECKING:
async def get_account_info(client: FragmentClient) -> dict[str, Any]: async def get_account_info(client: FragmentClient) -> dict[str, Any]:
"""Fetch wallet address, public key, and state-init for the Fragment API. """Build the wallet payload Fragment needs to prepare a transaction.
Fragment requires account info to build each transaction payload. The
returned dict is JSON-serialised and passed as the ``account`` field in
``getBuy*Link`` / ``get*Link`` requests.
Args: Args:
client: Authenticated :class:`FragmentClient` instance. client: Authenticated `FragmentClient` instance with seed and API key.
Returns: Returns:
Dict with ``address``, ``publicKey``, ``chain``, ``walletStateInit``. A JSON-serialisable dictionary containing address, public key, chain,
and wallet state-init bytes.
Raises:
WalletError: If account info cannot be retrieved.
""" """
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton: async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
try: try:
@@ -47,17 +41,14 @@ async def get_account_info(client: FragmentClient) -> dict[str, Any]:
async def get_wallet_info(client: FragmentClient) -> WalletInfo: async def get_wallet_info(client: FragmentClient) -> WalletInfo:
"""Return the address, state and balance of the TON wallet. """Fetch the wallet address, chain state, and TON/USDT balances.
Args: Args:
client: Authenticated :class:`FragmentClient` instance. client: Authenticated `FragmentClient` instance with seed and API key.
Returns: Returns:
:class:`WalletInfo` with ``address``, ``state``, ``balance`` in TON, `WalletInfo` with the friendly wallet address, current state,
and ``usdt_balance`` in USDT. TON balance, and USDT balance.
Raises:
WalletError: If the wallet state cannot be fetched.
""" """
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton: async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
try: try:
+15
View File
@@ -0,0 +1,15 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from pyfragment.domains.base import BaseService
from pyfragment.domains.tonapi.info import get_wallet_info
from pyfragment.models.wallet import WalletInfo
if TYPE_CHECKING:
pass
class TonapiService(BaseService):
async def get_wallet(self) -> WalletInfo:
return await get_wallet_info(self._client)
@@ -11,7 +11,7 @@ from tonutils.clients import TonapiClient
from tonutils.exceptions import ProviderResponseError from tonutils.exceptions import ProviderResponseError
from pyfragment.core.constants import WALLET_CLASSES from pyfragment.core.constants import WALLET_CLASSES
from pyfragment.domains.wallet.balance import check_ton_payment_balance, check_usdt_payment_balance from pyfragment.domains.tonapi.balance import check_ton_payment_balance, check_usdt_payment_balance
from pyfragment.exceptions import ParseError, TransactionError, WalletError from pyfragment.exceptions import ParseError, TransactionError, WalletError
from pyfragment.models.enums import PaymentMethod from pyfragment.models.enums import PaymentMethod
@@ -20,7 +20,12 @@ if TYPE_CHECKING:
def clean_decode(payload: str) -> str | Cell: def clean_decode(payload: str) -> str | Cell:
"""Decode a base64-encoded BOC payload to a plain-text comment string.""" """Decode a base64 BOC comment from Fragment into text when possible.
Some Fragment payloads are plain text comments, while others are structured
TON messages such as jetton transfers. Non-text payloads are returned as a
`Cell` so the caller can keep the raw binary structure.
"""
s = payload.strip() s = payload.strip()
if not s: if not s:
return "" return ""
@@ -31,8 +36,7 @@ def clean_decode(payload: str) -> str | Cell:
sl = cell.begin_parse() sl = cell.begin_parse()
op = sl.load_uint(32) op = sl.load_uint(32)
if op != 0: if op != 0:
# Non-zero op code means this is a structured message (e.g. jetton transfer), # Non-zero op code means this is a structured TON message, not a plain text comment.
# not a plain text comment — return the full cell as-is.
return cell return cell
try: try:
return sl.load_snake_string().strip() return sl.load_snake_string().strip()
@@ -48,23 +52,16 @@ async def process_transaction(
payment_method: PaymentMethod = "ton", payment_method: PaymentMethod = "ton",
required_payment_amount: float | None = None, required_payment_amount: float | None = None,
) -> str: ) -> str:
"""Sign and broadcast a Fragment transaction to the TON network. """Sign and broadcast a Fragment transaction with the seeded TON wallet.
Validates the payload structure, checks the wallet balance, decodes the
on-chain comment, and calls ``wallet.transfer``.
Args: Args:
client: Authenticated :class:`FragmentClient` instance. client: Authenticated `FragmentClient` instance.
transaction_data: Raw transaction dict from ``execute_transaction_request``. transaction_data: Raw Fragment transaction payload returned by the API.
payment_method: Payment currency ``"ton"`` or ``"usdt_ton"``. payment_method: Payment currency to use for the purchase flow.
required_payment_amount: Optional price from init*Request response. required_payment_amount: Optional amount returned by Fragment's init request.
Returns: Returns:
Normalised transaction hash string. Normalized transaction hash string.
Raises:
TransactionError: If the payload is malformed or the broadcast fails.
WalletError: If the wallet balance is too low or cannot be fetched.
""" """
if "transaction" not in transaction_data or not transaction_data["transaction"].get("messages"): if "transaction" not in transaction_data or not transaction_data["transaction"].get("messages"):
raise TransactionError(TransactionError.INVALID_PAYLOAD) raise TransactionError(TransactionError.INVALID_PAYLOAD)
@@ -76,7 +73,7 @@ async def process_transaction(
wallet_cls = WALLET_CLASSES[client.wallet_version] wallet_cls = WALLET_CLASSES[client.wallet_version]
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed) wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
# Check balance covers selected payment flow requirements. # Check balance first so we fail before trying to broadcast on-chain.
try: try:
await wallet.refresh() await wallet.refresh()
balance_ton = wallet.balance / 1_000_000_000 balance_ton = wallet.balance / 1_000_000_000
@@ -84,8 +81,7 @@ async def process_transaction(
wallet.address.to_str(False, False) wallet.address.to_str(False, False)
await check_ton_payment_balance(balance_ton, amount_ton, required_payment_amount) await check_ton_payment_balance(balance_ton, amount_ton, required_payment_amount)
else: else:
# USDT is withdrawn from the Fragment-linked wallet (transaction["from"]), # USDT is paid from the Fragment-linked wallet, not the signing wallet.
# not from the signing seed wallet. Seed wallet only pays TON gas.
fragment_wallet_address = transaction_data["transaction"].get("from", "") fragment_wallet_address = transaction_data["transaction"].get("from", "")
await check_usdt_payment_balance(balance_ton, required_payment_amount, ton, fragment_wallet_address) await check_usdt_payment_balance(balance_ton, required_payment_amount, ton, fragment_wallet_address)
except WalletError: except WalletError:
@@ -110,7 +106,6 @@ async def process_transaction(
await asyncio.sleep(1 + random.uniform(0, 0.5)) await asyncio.sleep(1 + random.uniform(0, 0.5))
continue continue
if exc.code == 406 and "seqno" in str(exc).lower(): if exc.code == 406 and "seqno" in str(exc).lower():
# Previous tx seqno not yet confirmed — wallet will re-fetch seqno on retry
if attempt < 2: if attempt < 2:
await asyncio.sleep(2 + random.uniform(0, 1)) await asyncio.sleep(2 + random.uniform(0, 1))
continue continue
@@ -20,19 +20,13 @@ async def send_ton_transfer(
amount: int, amount: int,
body: str | None = None, body: str | None = None,
) -> TonTransferResult: ) -> TonTransferResult:
"""Send a direct TON transfer on-chain using ToncenterClient. """Send a direct TON transfer from the seeded wallet.
Args: Args:
client: Authenticated :class:`FragmentClient` instance (seed and wallet_version used). client: Authenticated `FragmentClient` instance.
destination: Recipient TON address (any format, e.g. ``"UQ..."``). destination: Recipient address in any TON-compatible format.
amount: Amount in nanotons (1 TON = 1 000 000 000 nanotons). amount: Amount in nanotons.
body: Optional on-chain comment attached to the transfer. body: Optional on-chain comment.
Returns:
:class:`TonTransferResult` with ``transaction_id``, ``destination``, and ``amount``.
Raises:
TransactionError: If the transaction fails to broadcast.
""" """
try: try:
async with ToncenterClient(network=NetworkGlobalID.MAINNET) as ton: async with ToncenterClient(network=NetworkGlobalID.MAINNET) as ton:
@@ -63,20 +57,14 @@ async def send_usdt_transfer(
forward_payload: str | None = None, forward_payload: str | None = None,
ton_for_gas: int = 50_000_000, ton_for_gas: int = 50_000_000,
) -> UsdtTransferResult: ) -> UsdtTransferResult:
"""Send a direct USDT (TON jetton) transfer on-chain using ToncenterClient. """Send a direct USDT transfer from the seeded wallet.
Args: Args:
client: Authenticated :class:`FragmentClient` instance (seed and wallet_version used). client: Authenticated `FragmentClient` instance.
destination: Recipient TON address (any format, e.g. ``"UQ..."``). destination: Recipient address in any TON-compatible format.
usdt_amount: Amount in USDT base units (6 decimals; 1 USDT = 1 000 000). usdt_amount: Amount in USDT base units (6 decimals).
forward_payload: Optional comment forwarded to the recipient with the transfer notification. forward_payload: Optional comment passed through to the recipient.
ton_for_gas: TON attached for gas in nanotons. Defaults to ``50_000_000`` (0.05 TON). ton_for_gas: TON attached for gas in nanotons.
Returns:
:class:`UsdtTransferResult` with ``transaction_id``, ``destination``, and ``amount``.
Raises:
TransactionError: If the transaction fails to broadcast.
""" """
try: try:
async with ToncenterClient(network=NetworkGlobalID.MAINNET) as ton: async with ToncenterClient(network=NetworkGlobalID.MAINNET) as ton:
-1
View File
@@ -1 +0,0 @@
"""Wallet domain services."""
-20
View File
@@ -1,20 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from pyfragment.domains.base import BaseService
from pyfragment.domains.wallet.info import get_wallet_info
from pyfragment.domains.wallet.topup import topup_ton
from pyfragment.models.payments import AdsTopupResult
from pyfragment.models.wallet import WalletInfo
if TYPE_CHECKING:
pass
class WalletService(BaseService):
async def get_wallet(self) -> WalletInfo:
return await get_wallet_info(self._client)
async def topup_ton(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
return await topup_ton(self._client, username, amount, show_sender=show_sender)
-1
View File
@@ -1 +0,0 @@
"""Domain data models for pyfragment."""
+5 -5
View File
@@ -1,4 +1,4 @@
"""Tests for clean_decode() — TON BOC payload decoding.""" """Decode Fragment BOC payloads so comments become text and structured messages stay raw."""
import base64 import base64
import re import re
@@ -8,7 +8,7 @@ import pytest
from ton_core import Cell from ton_core import Cell
from pyfragment import ParseError from pyfragment import ParseError
from pyfragment.domains.wallet.transaction import clean_decode from pyfragment.domains.tonapi.transaction import clean_decode
PAYLOAD_CASES = [ PAYLOAD_CASES = [
pytest.param( pytest.param(
@@ -86,7 +86,7 @@ def test_decode_payload_accepts_base64url_alphabet() -> None:
raw = b"\xfb\xef\xff\x00" raw = b"\xfb\xef\xff\x00"
payload = base64.urlsafe_b64encode(raw).decode().rstrip("=") payload = base64.urlsafe_b64encode(raw).decode().rstrip("=")
with patch("pyfragment.domains.wallet.transaction.Cell.one_from_boc", return_value=_FakeCell()) as mocked: with patch("pyfragment.domains.tonapi.transaction.Cell.one_from_boc", return_value=_FakeCell()) as mocked:
result = clean_decode(payload) result = clean_decode(payload)
mocked.assert_called_once_with(raw) mocked.assert_called_once_with(raw)
@@ -106,7 +106,7 @@ def test_clean_decode_returns_text_comment_when_utf8() -> None:
return _FakeSlice() return _FakeSlice()
payload = base64.urlsafe_b64encode(b"\x00\x01").decode().rstrip("=") payload = base64.urlsafe_b64encode(b"\x00\x01").decode().rstrip("=")
with patch("pyfragment.domains.wallet.transaction.Cell.one_from_boc", return_value=_FakeCell()): with patch("pyfragment.domains.tonapi.transaction.Cell.one_from_boc", return_value=_FakeCell()):
parsed = clean_decode(payload) parsed = clean_decode(payload)
assert parsed == "Telegram Premium Ref#abc" assert parsed == "Telegram Premium Ref#abc"
@@ -126,7 +126,7 @@ def test_clean_decode_returns_cell_for_binary_payload() -> None:
payload = base64.urlsafe_b64encode(b"\x00\x01").decode().rstrip("=") payload = base64.urlsafe_b64encode(b"\x00\x01").decode().rstrip("=")
fake_cell: object = _FakeCell() fake_cell: object = _FakeCell()
with patch("pyfragment.domains.wallet.transaction.Cell.one_from_boc", return_value=fake_cell): with patch("pyfragment.domains.tonapi.transaction.Cell.one_from_boc", return_value=fake_cell):
parsed = clean_decode(payload) parsed = clean_decode(payload)
assert parsed is fake_cell assert parsed is fake_cell
+1 -1
View File
@@ -1,4 +1,4 @@
"""Unit tests for FragmentClient — initialization, validation, and cookie parsing.""" """Validate FragmentClient setup, cookie parsing, and wallet version checks."""
import json import json
+11 -11
View File
@@ -1,4 +1,4 @@
"""Unit tests for process_transaction() — balance validation and broadcast retry logic.""" """Exercise transaction signing, balance checks, and retry behavior for TON and USDT flows."""
from collections.abc import Generator from collections.abc import Generator
from contextlib import contextmanager from contextlib import contextmanager
@@ -8,7 +8,7 @@ import pytest
from tonutils.exceptions import ProviderResponseError from tonutils.exceptions import ProviderResponseError
from pyfragment import TransactionError, WalletError from pyfragment import TransactionError, WalletError
from pyfragment.domains.wallet.transaction import process_transaction from pyfragment.domains.tonapi.transaction import process_transaction
from tests.shared import VALID_SEED from tests.shared import VALID_SEED
@@ -48,8 +48,8 @@ def _make_wallet(balance_nanotons: int) -> MagicMock:
@contextmanager @contextmanager
def _patch_wallet(wallet: MagicMock) -> Generator[None, None, None]: def _patch_wallet(wallet: MagicMock) -> Generator[None, None, None]:
with ( with (
patch("pyfragment.domains.wallet.transaction.TonapiClient") as mock_tonapi, patch("pyfragment.domains.tonapi.transaction.TonapiClient") as mock_tonapi,
patch("pyfragment.domains.wallet.transaction.WALLET_CLASSES") as mock_classes, patch("pyfragment.domains.tonapi.transaction.WALLET_CLASSES") as mock_classes,
): ):
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock()) mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False) mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
@@ -63,7 +63,7 @@ def _patch_wallet(wallet: MagicMock) -> Generator[None, None, None]:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_sufficient_balance_broadcasts() -> None: async def test_sufficient_balance_broadcasts() -> None:
wallet = _make_wallet(balance_nanotons=1_000_000_000) # 1 TON, above threshold wallet = _make_wallet(balance_nanotons=1_000_000_000) # 1 TON, above threshold
with _patch_wallet(wallet), patch("pyfragment.domains.wallet.transaction.clean_decode", return_value="50 Telegram Stars"): with _patch_wallet(wallet), patch("pyfragment.domains.tonapi.transaction.clean_decode", return_value="50 Telegram Stars"):
result = await process_transaction(_make_client(), TRANSACTION_DATA) result = await process_transaction(_make_client(), TRANSACTION_DATA)
assert result == "abc123" assert result == "abc123"
wallet.transfer.assert_called_once() wallet.transfer.assert_called_once()
@@ -81,7 +81,7 @@ async def test_insufficient_balance_raises() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_exact_minimum_balance_broadcasts() -> None: async def test_exact_minimum_balance_broadcasts() -> None:
wallet = _make_wallet(balance_nanotons=500_000_000) # exactly transaction amount threshold wallet = _make_wallet(balance_nanotons=500_000_000) # exactly transaction amount threshold
with _patch_wallet(wallet), patch("pyfragment.domains.wallet.transaction.clean_decode", return_value="50 Telegram Stars"): with _patch_wallet(wallet), patch("pyfragment.domains.tonapi.transaction.clean_decode", return_value="50 Telegram Stars"):
result = await process_transaction(_make_client(), TRANSACTION_DATA) result = await process_transaction(_make_client(), TRANSACTION_DATA)
assert result == "abc123" assert result == "abc123"
@@ -123,7 +123,7 @@ async def test_balance_check_failed_raises_wallet_error() -> None:
async def test_rate_limit_retries_and_succeeds() -> None: async def test_rate_limit_retries_and_succeeds() -> None:
wallet = _make_wallet(balance_nanotons=1_000_000_000) wallet = _make_wallet(balance_nanotons=1_000_000_000)
wallet.transfer = AsyncMock(side_effect=[_provider_error(429, "rate limited"), MagicMock(normalized_hash="abc123")]) wallet.transfer = AsyncMock(side_effect=[_provider_error(429, "rate limited"), MagicMock(normalized_hash="abc123")])
with _patch_wallet(wallet), patch("pyfragment.domains.wallet.transaction.clean_decode", return_value=""): with _patch_wallet(wallet), patch("pyfragment.domains.tonapi.transaction.clean_decode", return_value=""):
result = await process_transaction(_make_client(), TRANSACTION_DATA) result = await process_transaction(_make_client(), TRANSACTION_DATA)
assert result == "abc123" assert result == "abc123"
assert wallet.transfer.call_count == 2 assert wallet.transfer.call_count == 2
@@ -134,7 +134,7 @@ async def test_duplicate_seqno_raises_after_retries() -> None:
wallet = _make_wallet(balance_nanotons=1_000_000_000) wallet = _make_wallet(balance_nanotons=1_000_000_000)
err = _provider_error(406, "Duplicate msg_seqno") err = _provider_error(406, "Duplicate msg_seqno")
wallet.transfer = AsyncMock(side_effect=[err, err, err]) wallet.transfer = AsyncMock(side_effect=[err, err, err])
with _patch_wallet(wallet), patch("pyfragment.domains.wallet.transaction.clean_decode", return_value=""): with _patch_wallet(wallet), patch("pyfragment.domains.tonapi.transaction.clean_decode", return_value=""):
with pytest.raises(TransactionError, match="seqno"): with pytest.raises(TransactionError, match="seqno"):
await process_transaction(_make_client(), TRANSACTION_DATA) await process_transaction(_make_client(), TRANSACTION_DATA)
assert wallet.transfer.call_count == 3 assert wallet.transfer.call_count == 3
@@ -143,7 +143,7 @@ async def test_duplicate_seqno_raises_after_retries() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_usdt_payment_requires_min_ton_gas_reserve() -> None: async def test_usdt_payment_requires_min_ton_gas_reserve() -> None:
wallet = _make_wallet(balance_nanotons=10_000_000) # 0.01 TON below MIN_TON_BALANCE wallet = _make_wallet(balance_nanotons=10_000_000) # 0.01 TON below MIN_TON_BALANCE
with _patch_wallet(wallet), patch("pyfragment.domains.wallet.balance.get_usdt_balance", AsyncMock(return_value=100.0)): with _patch_wallet(wallet), patch("pyfragment.domains.tonapi.balance.get_usdt_balance", AsyncMock(return_value=100.0)):
with pytest.raises(WalletError, match="Insufficient TON balance"): with pytest.raises(WalletError, match="Insufficient TON balance"):
await process_transaction(_make_client(), TRANSACTION_DATA, payment_method="usdt_ton") await process_transaction(_make_client(), TRANSACTION_DATA, payment_method="usdt_ton")
@@ -166,8 +166,8 @@ async def test_usdt_payment_checks_usdt_balance() -> None:
with ( with (
_patch_wallet(wallet), _patch_wallet(wallet),
patch("pyfragment.domains.wallet.transaction.clean_decode", return_value=""), patch("pyfragment.domains.tonapi.transaction.clean_decode", return_value=""),
patch("pyfragment.domains.wallet.balance.get_usdt_balance", AsyncMock(return_value=5.0)), patch("pyfragment.domains.tonapi.balance.get_usdt_balance", AsyncMock(return_value=5.0)),
): ):
with pytest.raises(WalletError, match="Insufficient USDT balance"): with pytest.raises(WalletError, match="Insufficient USDT balance"):
await process_transaction( await process_transaction(
+1 -1
View File
@@ -1,4 +1,4 @@
"""Unit tests for Stars methods — purchase_stars and giveaway_stars.""" """Cover stars purchase and giveaway flows, including validation and request wiring."""
import importlib import importlib
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
+1 -1
View File
@@ -1,4 +1,4 @@
"""Unit tests for Premium methods — purchase_premium and giveaway_premium.""" """Cover premium purchase and giveaway flows, including validation and request wiring."""
import importlib import importlib
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
+2 -2
View File
@@ -1,11 +1,11 @@
"""Unit tests for topup_ton — TON Ads balance top-up.""" """Cover TON top-up through Telegram Ads, including recipient lookup and transaction building."""
import importlib import importlib
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
_topup_ton_mod = importlib.import_module("pyfragment.domains.wallet.topup") _topup_ton_mod = importlib.import_module("pyfragment.domains.ads.tonup")
from pyfragment import AdsTopupResult, ConfigurationError, FragmentClient, UserNotFoundError from pyfragment import AdsTopupResult, ConfigurationError, FragmentClient, UserNotFoundError
from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
+7 -7
View File
@@ -1,4 +1,4 @@
"""Unit tests for get_wallet() — wallet address/state with separate TON and USDT balances.""" """Verify wallet inspection returns friendly TON and USDT balances from Tonapi."""
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
@@ -19,9 +19,9 @@ async def test_get_wallet_returns_wallet_info(client: FragmentClient) -> None:
mock_wallet.address.to_str.return_value = FAKE_ADDRESS mock_wallet.address.to_str.return_value = FAKE_ADDRESS
with ( with (
patch("pyfragment.domains.wallet.info.TonapiClient") as mock_tonapi, patch("pyfragment.domains.tonapi.info.TonapiClient") as mock_tonapi,
patch("pyfragment.domains.wallet.info.WALLET_CLASSES") as mock_classes, patch("pyfragment.domains.tonapi.info.WALLET_CLASSES") as mock_classes,
patch("pyfragment.domains.wallet.info.get_usdt_balance", AsyncMock(return_value=12.3456)), patch("pyfragment.domains.tonapi.info.get_usdt_balance", AsyncMock(return_value=12.3456)),
): ):
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock()) mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False) mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
@@ -45,9 +45,9 @@ async def test_get_wallet_balance_is_zero(client: FragmentClient) -> None:
mock_wallet.address.to_str.return_value = FAKE_ADDRESS mock_wallet.address.to_str.return_value = FAKE_ADDRESS
with ( with (
patch("pyfragment.domains.wallet.info.TonapiClient") as mock_tonapi, patch("pyfragment.domains.tonapi.info.TonapiClient") as mock_tonapi,
patch("pyfragment.domains.wallet.info.WALLET_CLASSES") as mock_classes, patch("pyfragment.domains.tonapi.info.WALLET_CLASSES") as mock_classes,
patch("pyfragment.domains.wallet.info.get_usdt_balance", AsyncMock(return_value=0.0)), patch("pyfragment.domains.tonapi.info.get_usdt_balance", AsyncMock(return_value=0.0)),
): ):
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock()) mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False) mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
+1 -5
View File
@@ -1,4 +1,4 @@
"""Unit tests for FragmentClient.call() — raw Fragment API access.""" """Check raw Fragment API calls and transport error handling."""
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
@@ -25,7 +25,6 @@ async def test_call_returns_api_response(client: FragmentClient) -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_call_default_page_url(client: FragmentClient) -> None: async def test_call_default_page_url(client: FragmentClient) -> None:
"""call() works without explicitly passing page_url (defaults to FRAGMENT_BASE_URL)."""
with ( with (
patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch("pyfragment.client.fragment_request", AsyncMock(return_value=FAKE_RESPONSE)), patch("pyfragment.client.fragment_request", AsyncMock(return_value=FAKE_RESPONSE)),
@@ -37,7 +36,6 @@ async def test_call_default_page_url(client: FragmentClient) -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_call_no_data(client: FragmentClient) -> None: async def test_call_no_data(client: FragmentClient) -> None:
"""call() with no extra data passes only the method field."""
mock_request = AsyncMock(return_value={}) mock_request = AsyncMock(return_value={})
with ( with (
@@ -52,7 +50,6 @@ async def test_call_no_data(client: FragmentClient) -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_call_merges_extra_data(client: FragmentClient) -> None: async def test_call_merges_extra_data(client: FragmentClient) -> None:
"""call() merges caller-supplied data with the method field."""
mock_request = AsyncMock(return_value={}) mock_request = AsyncMock(return_value={})
with ( with (
@@ -70,7 +67,6 @@ async def test_call_merges_extra_data(client: FragmentClient) -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_fragment_request_non_200_raises() -> None: async def test_fragment_request_non_200_raises() -> None:
"""fragment_request raises FragmentPageError on non-200 HTTP responses."""
response = MagicMock(spec=httpx.Response) response = MagicMock(spec=httpx.Response)
response.status_code = 429 response.status_code = 429
+1 -1
View File
@@ -1,4 +1,4 @@
"""Unit tests for recharge_ads — self-service Telegram Ads recharge.""" """Cover Telegram Ads recharge flow, including request preparation and KYC handling."""
import importlib import importlib
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
+1 -1
View File
@@ -1,4 +1,4 @@
"""Unit tests for search_usernames — Fragment marketplace username search.""" """Verify username search parsing and query forwarding."""
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
+1 -1
View File
@@ -1,4 +1,4 @@
"""Unit tests for search_numbers — Fragment marketplace number search.""" """Verify anonymous number search parsing and query forwarding."""
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
+1 -1
View File
@@ -1,4 +1,4 @@
"""Unit tests for search_gifts — Fragment gifts marketplace search.""" """Verify gift search parsing and pagination handling."""
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
+1 -1
View File
@@ -1,4 +1,4 @@
"""Unit tests for get_cookies_from_browser() — browser cookie extraction helper.""" """Extract Fragment cookies from browser stores and validate required keys."""
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
+1 -1
View File
@@ -1,4 +1,4 @@
"""Unit tests for init payment amount parsing.""" """Parse Fragment init responses to the payment amount the transaction should cover."""
from pyfragment.domains.payments import parse_required_payment_amount from pyfragment.domains.payments import parse_required_payment_amount
+5 -3
View File
@@ -1,3 +1,5 @@
"""Shared pytest fixtures for Fragment client tests."""
import json import json
import os import os
from typing import cast from typing import cast
@@ -5,16 +7,17 @@ from typing import cast
import pytest import pytest
import pyfragment.domains.ads.recharge # noqa: F401 import pyfragment.domains.ads.recharge # noqa: F401
import pyfragment.domains.ads.tonup # noqa: F401
import pyfragment.domains.giveaways.giveaway # noqa: F401 import pyfragment.domains.giveaways.giveaway # noqa: F401
import pyfragment.domains.purchases.purchase # noqa: F401 import pyfragment.domains.purchases.purchase # noqa: F401
import pyfragment.domains.wallet.topup # noqa: F401 import pyfragment.domains.tonapi.info # noqa: F401
import pyfragment.domains.tonapi.transaction # noqa: F401
from pyfragment import FragmentClient from pyfragment import FragmentClient
from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED
@pytest.fixture @pytest.fixture
def cookies() -> dict[str, str]: def cookies() -> dict[str, str]:
"""Load Fragment cookies from COOKIES_JSON env var; skip if unavailable."""
raw = os.environ.get("COOKIES_JSON") raw = os.environ.get("COOKIES_JSON")
if not raw: if not raw:
pytest.skip("COOKIES_JSON env var not set") pytest.skip("COOKIES_JSON env var not set")
@@ -26,5 +29,4 @@ def cookies() -> dict[str, str]:
@pytest.fixture @pytest.fixture
def client() -> FragmentClient: def client() -> FragmentClient:
"""Pre-built FragmentClient with dummy credentials."""
return FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES) return FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES)