refactor: reorganize tonapi module and update imports for account management

This commit is contained in:
bohd4nx
2026-05-20 23:45:27 +03:00
parent 01a5befd87
commit 2a6c5ef1f9
19 changed files with 187 additions and 174 deletions
+1
View File
@@ -0,0 +1 @@
"""Domain-level helpers for Fragment operations."""
+5
View File
@@ -0,0 +1,5 @@
from pyfragment.domains.ads.recharge import recharge_ads
from pyfragment.domains.ads.service import AdsService
from pyfragment.domains.ads.tonup import topup_ton
__all__ = ["AdsService", "recharge_ads", "topup_ton"]
+1 -1
View File
@@ -4,7 +4,7 @@ import json
from typing import TYPE_CHECKING
from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE
from pyfragment.domains.tonapi.info import get_account_info
from pyfragment.domains.tonapi.account import get_account_info
from pyfragment.domains.tonapi.transaction import process_transaction
from pyfragment.exceptions import ConfigurationError, FragmentAPIError, FragmentError, UnexpectedError, VerificationError
from pyfragment.models.payments import AdsRechargeResult
+1 -1
View File
@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING
from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE
from pyfragment.domains.payments import parse_required_payment_amount
from pyfragment.domains.tonapi.info import get_account_info
from pyfragment.domains.tonapi.account import get_account_info
from pyfragment.domains.tonapi.transaction import process_transaction
from pyfragment.exceptions import (
ConfigurationError,
@@ -0,0 +1,12 @@
from pyfragment.domains.anonymous_numbers.number import get_login_code, terminate_sessions, toggle_login_codes
from pyfragment.domains.anonymous_numbers.service import AnonymousNumbersService
from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult
__all__ = [
"AnonymousNumbersService",
"LoginCodeResult",
"TerminateSessionsResult",
"get_login_code",
"terminate_sessions",
"toggle_login_codes",
]
+11
View File
@@ -0,0 +1,11 @@
from pyfragment.domains.giveaways.giveaway import giveaway_premium, giveaway_stars
from pyfragment.domains.giveaways.service import GiveawaysService
from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult
__all__ = [
"GiveawaysService",
"PremiumGiveawayResult",
"StarsGiveawayResult",
"giveaway_premium",
"giveaway_stars",
]
+1 -1
View File
@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, get_args
from pyfragment.core.constants import DEVICE, PREMIUM_GIVEAWAY_PAGE, STARS_GIVEAWAY_PAGE
from pyfragment.domains.payments import parse_required_payment_amount
from pyfragment.domains.tonapi.info import get_account_info
from pyfragment.domains.tonapi.account import get_account_info
from pyfragment.domains.tonapi.transaction import process_transaction
from pyfragment.exceptions import (
ConfigurationError,
@@ -0,0 +1,13 @@
from pyfragment.domains.marketplace.search import search_gifts, search_numbers, search_usernames
from pyfragment.domains.marketplace.service import MarketplaceService
from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult
__all__ = [
"GiftsResult",
"MarketplaceService",
"NumbersResult",
"UsernamesResult",
"search_gifts",
"search_numbers",
"search_usernames",
]
+5
View File
@@ -0,0 +1,5 @@
from pyfragment.domains.purchases.purchase import purchase_premium, purchase_stars
from pyfragment.domains.purchases.service import PurchasesService
from pyfragment.models.payments import PremiumResult, StarsResult
__all__ = ["PremiumResult", "PurchasesService", "StarsResult", "purchase_premium", "purchase_stars"]
+1 -1
View File
@@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, get_args
from pyfragment.core.constants import DEVICE, PREMIUM_PAGE, STARS_PAGE
from pyfragment.domains.payments import parse_required_payment_amount
from pyfragment.domains.tonapi.info import get_account_info
from pyfragment.domains.tonapi.account import get_account_info
from pyfragment.domains.tonapi.transaction import process_transaction
from pyfragment.exceptions import (
ConfigurationError,
+23
View File
@@ -0,0 +1,23 @@
from pyfragment.domains.tonapi.account import (
check_ton_payment_balance,
check_usdt_payment_balance,
get_account_info,
get_usdt_balance,
get_wallet_info,
)
from pyfragment.domains.tonapi.service import TonapiService
from pyfragment.domains.tonapi.transaction import clean_decode, process_transaction
from pyfragment.domains.tonapi.transfer import send_ton_transfer, send_usdt_transfer
__all__ = [
"TonapiService",
"clean_decode",
"check_ton_payment_balance",
"check_usdt_payment_balance",
"get_account_info",
"get_usdt_balance",
"get_wallet_info",
"process_transaction",
"send_ton_transfer",
"send_usdt_transfer",
]
+102
View File
@@ -0,0 +1,102 @@
from __future__ import annotations
import base64
from typing import TYPE_CHECKING, Any
from ton_core import NetworkGlobalID
from tonutils.clients import TonapiClient
from tonutils.contracts.jetton import get_wallet_address_get_method, get_wallet_data_get_method
from tonutils.exceptions import ProviderResponseError
from pyfragment.core.constants import MIN_TON_BALANCE, MIN_USDT_BALANCE, USDT_TON_MASTER_ADDRESS, WALLET_CLASSES
from pyfragment.exceptions import WalletError
from pyfragment.models.wallet import WalletInfo
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
async def get_usdt_balance(ton: Any, wallet_address: str) -> float:
"""Return the USDT balance for a Fragment-linked TON wallet."""
try:
jetton_wallet_address = await get_wallet_address_get_method(
client=ton,
address=USDT_TON_MASTER_ADDRESS,
owner_address=wallet_address,
)
wallet_data = await get_wallet_data_get_method(client=ton, address=jetton_wallet_address)
raw_balance = int(wallet_data[0]) if wallet_data else 0
return float(raw_balance) / 1_000_000.0
except ProviderResponseError as exc:
if exc.code == 404:
return 0.0
raise WalletError(WalletError.USDT_BALANCE_CHECK_FAILED.format(exc=exc)) from exc
except Exception as exc:
raise WalletError(WalletError.USDT_BALANCE_CHECK_FAILED.format(exc=exc)) from exc
async def check_ton_payment_balance(
balance_ton: float,
amount_ton: float,
required_payment_amount: float | None,
) -> None:
"""Validate that the TON wallet can cover a TON-denominated payment."""
tx_price_ton = amount_ton
if required_payment_amount is not None and required_payment_amount > 0:
tx_price_ton = max(tx_price_ton, required_payment_amount)
required_ton = max(tx_price_ton, MIN_TON_BALANCE)
if balance_ton < required_ton:
raise WalletError(WalletError.LOW_TON_BALANCE.format(balance=balance_ton, required=required_ton))
async def check_usdt_payment_balance(
balance_ton: float,
required_payment_amount: float | None,
ton: Any,
wallet_address: str,
) -> None:
"""Validate that the wallet can cover a USDT-denominated payment."""
if balance_ton < MIN_TON_BALANCE:
raise WalletError(WalletError.LOW_TON_BALANCE.format(balance=balance_ton, required=MIN_TON_BALANCE))
usdt_balance = await get_usdt_balance(ton, wallet_address)
required_usdt = required_payment_amount if required_payment_amount is not None else MIN_USDT_BALANCE
if usdt_balance < required_usdt:
raise WalletError(WalletError.LOW_USDT_BALANCE.format(balance=usdt_balance, required=required_usdt))
async def get_account_info(client: FragmentClient) -> dict[str, Any]:
"""Build the wallet payload Fragment needs to prepare a transaction."""
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
try:
wallet_cls = WALLET_CLASSES[client.wallet_version]
wallet, pub_key, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.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(WalletError.ACCOUNT_INFO_FAILED.format(exc=exc)) from exc
async def get_wallet_info(client: FragmentClient) -> WalletInfo:
"""Fetch the wallet address, chain state, and TON/USDT balances."""
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
try:
wallet_cls = WALLET_CLASSES[client.wallet_version]
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
await wallet.refresh()
wallet_address = wallet.address.to_str(False, False)
usdt_balance = await get_usdt_balance(ton, wallet_address)
return WalletInfo(
address=wallet.address.to_str(is_user_friendly=True, is_bounceable=False),
state=wallet.state.value,
ton_balance=round(wallet.balance / 1_000_000_000, 4),
usdt_balance=round(usdt_balance, 4),
)
except Exception as exc:
raise WalletError(WalletError.WALLET_INFO_FAILED.format(exc=exc)) from exc
-92
View File
@@ -1,92 +0,0 @@
from __future__ import annotations
from typing import Any
from tonutils.contracts.jetton import get_wallet_address_get_method, get_wallet_data_get_method
from tonutils.exceptions import ProviderResponseError
from pyfragment.core.constants import MIN_TON_BALANCE, MIN_USDT_BALANCE, USDT_TON_MASTER_ADDRESS
from pyfragment.exceptions import WalletError
async def get_usdt_balance(ton: Any, wallet_address: str) -> float:
"""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:
jetton_wallet_address = await get_wallet_address_get_method(
client=ton,
address=USDT_TON_MASTER_ADDRESS,
owner_address=wallet_address,
)
wallet_data = await get_wallet_data_get_method(client=ton, address=jetton_wallet_address)
raw_balance = int(wallet_data[0]) if wallet_data else 0
return float(raw_balance) / 1_000_000.0
except ProviderResponseError as exc:
# No jetton wallet deployed yet means the balance is effectively zero.
if exc.code == 404:
return 0.0
raise WalletError(WalletError.USDT_BALANCE_CHECK_FAILED.format(exc=exc)) from exc
except Exception as exc:
raise WalletError(WalletError.USDT_BALANCE_CHECK_FAILED.format(exc=exc)) from exc
async def check_ton_payment_balance(
balance_ton: float,
amount_ton: float,
required_payment_amount: float | None,
) -> None:
"""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
if required_payment_amount is not None and required_payment_amount > 0:
tx_price_ton = max(tx_price_ton, required_payment_amount)
required_ton = max(tx_price_ton, MIN_TON_BALANCE)
if balance_ton < required_ton:
raise WalletError(
WalletError.LOW_TON_BALANCE.format(
balance=balance_ton,
required=required_ton,
)
)
async def check_usdt_payment_balance(
balance_ton: float,
required_payment_amount: float | None,
ton: Any,
wallet_address: str,
) -> None:
"""Validate that the wallet can cover a USDT-denominated payment.
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:
raise WalletError(
WalletError.LOW_TON_BALANCE.format(
balance=balance_ton,
required=MIN_TON_BALANCE,
)
)
usdt_balance = await get_usdt_balance(ton, wallet_address)
required_usdt = required_payment_amount if required_payment_amount is not None else MIN_USDT_BALANCE
if usdt_balance < required_usdt:
raise WalletError(WalletError.LOW_USDT_BALANCE.format(balance=usdt_balance, required=required_usdt))
-67
View File
@@ -1,67 +0,0 @@
from __future__ import annotations
import base64
from typing import TYPE_CHECKING, Any
from ton_core import NetworkGlobalID
from tonutils.clients import TonapiClient
from pyfragment.core.constants import WALLET_CLASSES
from pyfragment.domains.tonapi.balance import get_usdt_balance
from pyfragment.exceptions import WalletError
from pyfragment.models.wallet import WalletInfo
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
async def get_account_info(client: FragmentClient) -> dict[str, Any]:
"""Build the wallet payload Fragment needs to prepare a transaction.
Args:
client: Authenticated `FragmentClient` instance with seed and API key.
Returns:
A JSON-serialisable dictionary containing address, public key, chain,
and wallet state-init bytes.
"""
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
try:
wallet_cls = WALLET_CLASSES[client.wallet_version]
wallet, pub_key, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.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(WalletError.ACCOUNT_INFO_FAILED.format(exc=exc)) from exc
async def get_wallet_info(client: FragmentClient) -> WalletInfo:
"""Fetch the wallet address, chain state, and TON/USDT balances.
Args:
client: Authenticated `FragmentClient` instance with seed and API key.
Returns:
`WalletInfo` with the friendly wallet address, current state,
TON balance, and USDT balance.
"""
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
try:
wallet_cls = WALLET_CLASSES[client.wallet_version]
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
await wallet.refresh()
wallet_address = wallet.address.to_str(False, False)
usdt_balance = await get_usdt_balance(ton, wallet_address)
return WalletInfo(
address=wallet.address.to_str(is_user_friendly=True, is_bounceable=False),
state=wallet.state.value,
ton_balance=round(wallet.balance / 1_000_000_000, 4),
usdt_balance=round(usdt_balance, 4),
)
except Exception as exc:
raise WalletError(WalletError.WALLET_INFO_FAILED.format(exc=exc)) from exc
+1 -1
View File
@@ -3,7 +3,7 @@ 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.domains.tonapi.account import get_wallet_info
from pyfragment.models.wallet import WalletInfo
if TYPE_CHECKING:
+1 -1
View File
@@ -11,7 +11,7 @@ from tonutils.clients import TonapiClient
from tonutils.exceptions import ProviderResponseError
from pyfragment.core.constants import WALLET_CLASSES
from pyfragment.domains.tonapi.balance import check_ton_payment_balance, check_usdt_payment_balance
from pyfragment.domains.tonapi.account import check_ton_payment_balance, check_usdt_payment_balance
from pyfragment.exceptions import ParseError, TransactionError, WalletError
from pyfragment.models.enums import PaymentMethod
+2 -2
View File
@@ -143,7 +143,7 @@ async def test_duplicate_seqno_raises_after_retries() -> None:
@pytest.mark.asyncio
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
with _patch_wallet(wallet), patch("pyfragment.domains.tonapi.balance.get_usdt_balance", AsyncMock(return_value=100.0)):
with _patch_wallet(wallet), patch("pyfragment.domains.tonapi.account.get_usdt_balance", AsyncMock(return_value=100.0)):
with pytest.raises(WalletError, match="Insufficient TON balance"):
await process_transaction(_make_client(), TRANSACTION_DATA, payment_method="usdt_ton")
@@ -167,7 +167,7 @@ async def test_usdt_payment_checks_usdt_balance() -> None:
with (
_patch_wallet(wallet),
patch("pyfragment.domains.tonapi.transaction.clean_decode", return_value=""),
patch("pyfragment.domains.tonapi.balance.get_usdt_balance", AsyncMock(return_value=5.0)),
patch("pyfragment.domains.tonapi.account.get_usdt_balance", AsyncMock(return_value=5.0)),
):
with pytest.raises(WalletError, match="Insufficient USDT balance"):
await process_transaction(
+6 -6
View File
@@ -19,9 +19,9 @@ async def test_get_wallet_returns_wallet_info(client: FragmentClient) -> None:
mock_wallet.address.to_str.return_value = FAKE_ADDRESS
with (
patch("pyfragment.domains.tonapi.info.TonapiClient") as mock_tonapi,
patch("pyfragment.domains.tonapi.info.WALLET_CLASSES") as mock_classes,
patch("pyfragment.domains.tonapi.info.get_usdt_balance", AsyncMock(return_value=12.3456)),
patch("pyfragment.domains.tonapi.account.TonapiClient") as mock_tonapi,
patch("pyfragment.domains.tonapi.account.WALLET_CLASSES") as mock_classes,
patch("pyfragment.domains.tonapi.account.get_usdt_balance", AsyncMock(return_value=12.3456)),
):
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
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
with (
patch("pyfragment.domains.tonapi.info.TonapiClient") as mock_tonapi,
patch("pyfragment.domains.tonapi.info.WALLET_CLASSES") as mock_classes,
patch("pyfragment.domains.tonapi.info.get_usdt_balance", AsyncMock(return_value=0.0)),
patch("pyfragment.domains.tonapi.account.TonapiClient") as mock_tonapi,
patch("pyfragment.domains.tonapi.account.WALLET_CLASSES") as mock_classes,
patch("pyfragment.domains.tonapi.account.get_usdt_balance", AsyncMock(return_value=0.0)),
):
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
+1 -1
View File
@@ -10,7 +10,7 @@ import pyfragment.domains.ads.recharge # noqa: F401
import pyfragment.domains.ads.tonup # noqa: F401
import pyfragment.domains.giveaways.giveaway # noqa: F401
import pyfragment.domains.purchases.purchase # noqa: F401
import pyfragment.domains.tonapi.info # noqa: F401
import pyfragment.domains.tonapi.account # noqa: F401
import pyfragment.domains.tonapi.transaction # noqa: F401
from pyfragment import FragmentClient
from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED