Refactor tonapi module: Move account and transaction logic to services

- Moved account-related functions and classes from `pyfragment.domains.tonapi.account` to `pyfragment.services.tonapi.account`.
- Moved transaction-related functions and classes from `pyfragment.domains.tonapi.transaction` to `pyfragment.services.tonapi.transaction`.
- Updated imports across the codebase to reflect the new structure.
- Removed unused `tonapi` module files and cleaned up related code.
- Introduced `ApiProvider` enum to manage API provider types.
- Added validation functions for cookies and wallet versions in a new `validation.py` module.
This commit is contained in:
bohd4nx
2026-06-16 01:37:26 +03:00
parent dc9661134d
commit 228fbfcd1c
35 changed files with 286 additions and 227 deletions
+4
View File
@@ -11,6 +11,10 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
### Added
- Added `ApiProvider` enum with `TONAPI` (tonconsole.com, default) and `TONCENTER` (t.me/toncenter) values.
- Added `api_provider` parameter to `FragmentClient` — select the blockchain API provider at init time (`"tonapi"` or `"toncenter"`).
- Both providers accept `api_key` with the same interface; the correct `tonutils` client is selected automatically.
- New `AlreadySubscribedError` exception for Premium purchase flows when Fragment returns: `This account is already subscribed to Telegram Premium.`
- New `UserNotFoundError.NOT_A_USER` message for when Fragment returns: `Please enter a username assigned to a user.` (e.g. when the username belongs to a channel or bot).
- Added `WalletVersion.HighloadV2` and `WalletVersion.HighloadV3R1` to `WalletVersion`
+11 -8
View File
@@ -46,13 +46,15 @@ from pyfragment.enums import PaymentMethod
async def main() -> None:
async with FragmentClient(
seed="word1 word2 ... word24",
api_key="YOUR_TONAPI_KEY",
api_key="YOUR_API_KEY", # tonconsole.com (tonapi, default) or t.me/toncenter
cookies={
"stel_ssid": "...",
"stel_dt": "...",
"stel_token": "...",
"stel_ton_token": "...",
},
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
api_provider="tonapi", # or "toncenter"
) as client:
wallet = await client.get_wallet()
print(f"GRAM: {wallet.gram_balance} | USDT: {wallet.usdt_balance}")
@@ -73,13 +75,14 @@ asyncio.run(main())
## Configuration
| Parameter | Type | Default | Description |
| ---------------- | ------------- | -------- | ----------------------------------------------------------- |
| `seed` | `str` | — | 12- or 24-word GRAM (ex TON) wallet mnemonic |
| `api_key` | `str` | — | Tonapi key from [tonconsole.com](https://tonconsole.com) |
| `cookies` | `dict \| str` | — | Fragment session cookies |
| `wallet_version` | `str` | `"V5R1"` | `"V4R2"` or `"V5R1"` — also accepts `WalletVersion` literal |
| `timeout` | `float` | `30.0` | HTTP request timeout in seconds |
| Parameter | Type | Default | Description |
| ---------------- | ------------- | ----------- | ------------------------------------------------------------------------------ |
| `seed` | `str` | — | 12- or 24-word GRAM (ex TON) wallet mnemonic |
| `api_key` | `str` | — | API key for the chosen provider (see `api_provider`) |
| `cookies` | `dict \| str` | — | Fragment session cookies |
| `wallet_version` | `str` | `"V5R1"` | `"V4R2"` or `"V5R1"` — also accepts `WalletVersion` literal |
| `api_provider` | `str` | `"tonapi"` | `"tonapi"` ([tonconsole.com](https://tonconsole.com)) or `"toncenter"` ([t.me/toncenter](https://t.me/toncenter)) |
| `timeout` | `float` | `30.0` | HTTP request timeout in seconds |
---
+8 -2
View File
@@ -13,7 +13,7 @@ import json
from pyfragment import FragmentClient, GiftsResult
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
@@ -33,7 +33,13 @@ FILTER = "" # "", "auction", "sale", "sold" — or omit
async def main() -> None:
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
async with FragmentClient(
seed=SEED,
api_key=API_KEY,
cookies=COOKIES,
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
api_provider="tonapi", # or "toncenter"
) as client:
result: GiftsResult = await client.search_gifts(QUERY, collection=COLLECTION, sort=SORT, filter=FILTER)
print(f"Found {len(result.items)} result(s):")
+8 -2
View File
@@ -12,7 +12,7 @@ import json
from pyfragment import FragmentClient, NumbersResult
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
@@ -31,7 +31,13 @@ FILTER = "" # "", "auction", "sale", "sold" — or omit
async def main() -> None:
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
async with FragmentClient(
seed=SEED,
api_key=API_KEY,
cookies=COOKIES,
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
api_provider="tonapi", # or "toncenter"
) as client:
result: NumbersResult = await client.search_numbers(QUERY, sort=SORT, filter=FILTER)
print(f"Found {len(result.items)} result(s):")
+8 -2
View File
@@ -12,7 +12,7 @@ import json
from pyfragment import FragmentClient, UsernamesResult
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
@@ -31,7 +31,13 @@ FILTER = "auction" # "", "auction", "sale", "sold" — or omit
async def main() -> None:
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
async with FragmentClient(
seed=SEED,
api_key=API_KEY,
cookies=COOKIES,
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
api_provider="tonapi", # or "toncenter"
) as client:
result: UsernamesResult = await client.search_usernames(QUERY, sort=SORT, filter=FILTER)
print(f"Found {len(result.items)} result(s):")
+8 -2
View File
@@ -14,7 +14,7 @@ import asyncio
from pyfragment import FragmentClient
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
@@ -33,7 +33,13 @@ PAGE_URL = "https://fragment.com/stars/buy" # replace with the matching Fragmen
async def main() -> None:
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
async with FragmentClient(
seed=SEED,
api_key=API_KEY,
cookies=COOKIES,
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
api_provider="tonapi", # or "toncenter"
) as client:
result = await client.call(METHOD, DATA, page_url=PAGE_URL)
print(result)
+4 -2
View File
@@ -3,6 +3,7 @@ Example: fetch wallet address, state, and separate GRAM (ex TON)/USDT balances.
Cookies can be passed as a dict or as a JSON string.
wallet_version defaults to "V5R1" — change to "V4R2" for older wallets.
api_provider defaults to "tonapi" (tonconsole.com) — pass "toncenter" to use t.me/toncenter instead.
"""
import asyncio
@@ -10,7 +11,7 @@ import asyncio
from pyfragment import FragmentClient
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
@@ -29,7 +30,8 @@ async def main() -> None:
seed=SEED,
api_key=API_KEY,
cookies=COOKIES,
wallet_version="V5R1", # or "V4R2"
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
api_provider="tonapi", # or "toncenter"
) as client:
wallet = await client.get_wallet()
print(f"Address: {wallet.address}")
+8 -2
View File
@@ -11,7 +11,7 @@ import asyncio
from pyfragment import AnonymousNumberError, FragmentClient
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
@@ -28,7 +28,13 @@ NUMBER = "+88888888888"
async def main() -> None:
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
async with FragmentClient(
seed=SEED,
api_key=API_KEY,
cookies=COOKIES,
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
api_provider="tonapi", # or "toncenter"
) as client:
# Fetch the latest login code
result = await client.get_login_code(NUMBER)
if result.code:
+8 -2
View File
@@ -12,7 +12,7 @@ from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
from pyfragment.enums import PaymentMethod
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
@@ -32,7 +32,13 @@ PAYMENT_METHOD = PaymentMethod.GRAM # GRAM, USDT_GRAM, USDT_ETH, USDT_POL, USDC
async def main() -> None:
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
async with FragmentClient(
seed=SEED,
api_key=API_KEY,
cookies=COOKIES,
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
api_provider="tonapi", # or "toncenter"
) as client:
try:
result = await client.giveaway_premium(
CHANNEL,
+8 -2
View File
@@ -12,7 +12,7 @@ from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
from pyfragment.enums import PaymentMethod
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
@@ -31,7 +31,13 @@ PAYMENT_METHOD = PaymentMethod.GRAM # GRAM, USDT_GRAM, USDT_ETH, USDT_POL, USDC
async def main() -> None:
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
async with FragmentClient(
seed=SEED,
api_key=API_KEY,
cookies=COOKIES,
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
api_provider="tonapi", # or "toncenter"
) as client:
try:
result = await client.purchase_premium(
USERNAME,
+8 -2
View File
@@ -12,7 +12,7 @@ from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
from pyfragment.enums import PaymentMethod
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
@@ -31,7 +31,13 @@ PAYMENT_METHOD = PaymentMethod.USDT_GRAM # GRAM, USDT_GRAM, USDT_ETH, USDT_POL,
async def main() -> None:
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
async with FragmentClient(
seed=SEED,
api_key=API_KEY,
cookies=COOKIES,
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
api_provider="tonapi", # or "toncenter"
) as client:
try:
result = await client.purchase_stars(
USERNAME,
+8 -2
View File
@@ -15,7 +15,7 @@ from pyfragment import (
)
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
@@ -33,7 +33,13 @@ AMOUNT = 10 # 11 000 000 000 GRAM (ex TON)
async def main() -> None:
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
async with FragmentClient(
seed=SEED,
api_key=API_KEY,
cookies=COOKIES,
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
api_provider="tonapi", # or "toncenter"
) as client:
try:
result: AdsRechargeResult = await client.recharge_ads(ACCOUNT, amount=AMOUNT)
except WalletError as e:
+8 -2
View File
@@ -12,7 +12,7 @@ from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
from pyfragment.enums import PaymentMethod
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
@@ -32,7 +32,13 @@ PAYMENT_METHOD = PaymentMethod.USDT_GRAM # GRAM, USDT_GRAM, USDT_ETH, USDT_POL,
async def main() -> None:
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
async with FragmentClient(
seed=SEED,
api_key=API_KEY,
cookies=COOKIES,
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
api_provider="tonapi", # or "toncenter"
) as client:
try:
result = await client.giveaway_stars(
CHANNEL,
+8 -2
View File
@@ -17,7 +17,7 @@ from pyfragment import (
)
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
@@ -35,7 +35,13 @@ AMOUNT = 10 # 11 000 000 000 GRAM (ex TON)
async def main() -> None:
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
async with FragmentClient(
seed=SEED,
api_key=API_KEY,
cookies=COOKIES,
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
api_provider="tonapi", # or "toncenter"
) as client:
try:
result = await client.topup_gram(USERNAME, amount=AMOUNT, show_sender=True)
except UserNotFoundError:
+3 -2
View File
@@ -7,8 +7,7 @@ from pyfragment.domains.anonymous_numbers.models import LoginCodeResult, Termina
from pyfragment.domains.giveaways.models import PremiumGiveawayResult, StarsGiveawayResult
from pyfragment.domains.marketplace.models import GiftsResult, NumbersResult, UsernamesResult
from pyfragment.domains.purchases.models import PremiumResult, StarsResult
from pyfragment.domains.tonapi.models import WalletInfo
from pyfragment.enums import PaymentMethod, WalletVersion
from pyfragment.enums import ApiProvider, PaymentMethod, WalletVersion
from pyfragment.exceptions import (
AlreadySubscribedError,
AnonymousNumberError,
@@ -27,6 +26,7 @@ from pyfragment.exceptions import (
WalletError,
)
from pyfragment.services.cookies import CookieResult, get_cookies_from_browser
from pyfragment.services.tonapi.models import WalletInfo
logging.getLogger("pyfragment").addHandler(logging.NullHandler())
@@ -66,6 +66,7 @@ __all__ = [
"ParseError",
"UnexpectedError",
# literal types
"ApiProvider",
"PaymentMethod",
"WalletVersion",
"get_cookies_from_browser",
+54 -129
View File
@@ -1,15 +1,14 @@
from __future__ import annotations
import json
from typing import Any, cast
from typing import Any
from pyfragment.core.constants import (
BASE_HEADERS,
DEFAULT_TIMEOUT,
FRAGMENT_BASE_URL,
MNEMONIC_WORD_COUNTS_VALID,
REQUIRED_COOKIE_KEYS,
TONAPI_KEY_MIN_LENGTH,
from pyfragment.core.constants import BASE_HEADERS, DEFAULT_TIMEOUT, FRAGMENT_BASE_URL
from pyfragment.core.validation import (
normalize_provider,
normalize_wallet_version,
parse_cookies,
validate_cookie_keys,
validate_credentials,
)
from pyfragment.domains.ads.models import AdsRechargeResult, AdsTopupResult
from pyfragment.domains.ads.service import AdsService
@@ -22,10 +21,9 @@ from pyfragment.domains.marketplace.models import GiftsResult, NumbersResult, Us
from pyfragment.domains.marketplace.service import MarketplaceService
from pyfragment.domains.purchases.models import PremiumResult, StarsResult
from pyfragment.domains.purchases.service import PurchasesService
from pyfragment.domains.tonapi.models import WalletInfo
from pyfragment.domains.tonapi.service import TonapiService
from pyfragment.enums import PaymentMethod, WalletVersion
from pyfragment.exceptions import ConfigurationError, CookieError
from pyfragment.enums import ApiProvider, PaymentMethod, WalletVersion
from pyfragment.services.tonapi.models import WalletInfo
from pyfragment.services.tonapi.service import TonapiService
class FragmentClient:
@@ -38,14 +36,17 @@ class FragmentClient:
Args:
seed: 12- or 24-word mnemonic phrase for the GRAM (ex TON) wallet.
api_key: Tonapi API key — get one at https://tonconsole.com.
api_key: API key for the chosen provider — tonconsole.com (default) or t.me/toncenter.
cookies: Fragment session cookies as a dict or JSON string.
wallet_version: Wallet contract version — ``"V4R2"`` or ``"V5R1"`` (default).
api_provider: Blockchain API provider — ``"tonapi"`` (tonconsole.com, default)
or ``"toncenter"`` (t.me/toncenter).
timeout: HTTP request timeout in seconds. Defaults to ``30.0``.
headers: Custom HTTP request headers. If omitted, :data:`BASE_HEADERS` is used.
Raises:
ConfigurationError: If ``seed``, ``api_key``, or ``wallet_version`` are missing or invalid.
ConfigurationError: If ``seed``, ``api_key``, ``wallet_version``, or ``api_provider``
are missing or invalid.
CookieError: If ``cookies`` cannot be parsed or are missing required keys.
Example::
@@ -60,62 +61,25 @@ class FragmentClient:
print(result.transaction_id)
"""
@staticmethod
def _parse_cookies(cookies: dict[str, Any] | str) -> dict[str, Any]:
if isinstance(cookies, str):
try:
cookies = json.loads(cookies)
except Exception as exc:
raise CookieError(CookieError.READ_FAILED.format(exc=exc)) from exc
return cast(dict[str, Any], cookies)
@staticmethod
def _validate_required(seed: str, api_key: str) -> None:
missing = [name for name, val in (("seed", seed), ("api_key", api_key)) if not val or not str(val).strip()]
if missing:
raise ConfigurationError(ConfigurationError.MISSING_VARS.format(keys=", ".join(missing)))
word_count = len(seed.split())
if word_count not in MNEMONIC_WORD_COUNTS_VALID:
raise ConfigurationError(ConfigurationError.INVALID_MNEMONIC.format(count=word_count))
if len(api_key.strip()) < TONAPI_KEY_MIN_LENGTH:
raise ConfigurationError(ConfigurationError.INVALID_API_KEY.format(length=len(api_key.strip())))
@staticmethod
def _validate_cookie_keys(cookies: dict[str, Any]) -> None:
missing_keys = [k for k in REQUIRED_COOKIE_KEYS if not str(cookies.get(k, "")).strip()]
if missing_keys:
raise CookieError(CookieError.MISSING_KEYS.format(keys=", ".join(missing_keys)))
@staticmethod
def _normalize_wallet_version(wallet_version: str) -> WalletVersion:
version = wallet_version.strip().upper()
try:
return WalletVersion(version)
except ValueError:
raise ConfigurationError(
ConfigurationError.UNSUPPORTED_VERSION.format(
version=version, supported=", ".join(sorted(m.value for m in WalletVersion))
)
)
def __init__(
self,
seed: str,
api_key: str,
cookies: dict[str, Any] | str,
wallet_version: str = "V5R1",
api_provider: str = "tonapi",
timeout: float = DEFAULT_TIMEOUT,
headers: dict[str, str] | None = None,
) -> None:
self._validate_required(seed, api_key)
parsed_cookies = self._parse_cookies(cookies)
self._validate_cookie_keys(parsed_cookies)
version = self._normalize_wallet_version(wallet_version)
validate_credentials(seed, api_key)
provider = normalize_provider(api_provider)
parsed_cookies = parse_cookies(cookies)
validate_cookie_keys(parsed_cookies)
version = normalize_wallet_version(wallet_version)
self.seed: str = seed.strip()
self.api_key: str = api_key.strip()
self.api_provider: ApiProvider = provider
self.cookies: dict[str, Any] = parsed_cookies
self.wallet_version: WalletVersion = version
self.timeout: float = timeout
@@ -134,7 +98,7 @@ class FragmentClient:
pass
def __repr__(self) -> str:
return f"FragmentClient(wallet_version='{self.wallet_version}', cookies={len(self.cookies)} keys)"
return f"FragmentClient(wallet_version='{self.wallet_version}', api_provider='{self.api_provider}', cookies={len(self.cookies)} keys)"
async def purchase_premium(
self,
@@ -149,9 +113,7 @@ class FragmentClient:
username: Recipient identifier — ``@username``, ``username``, or ``https://t.me/username``.
months: Duration — ``3``, ``6``, or ``12``.
show_sender: Show your name as the sender. Defaults to ``True``.
payment_method: Payment currency — ``"ton"`` (GRAM (ex TON), default), ``"usdt_ton"`` (USDT on GRAM (ex TON)),
``"usdt_eth"``, ``"usdt_pol"``, ``"usdc_eth"``, ``"usdc_base"``,
or ``"usdc_pol"``.
payment_method: Payment currency — defaults to ``PaymentMethod.GRAM``.
Returns:
:class:`PremiumResult` with ``transaction_id``, ``username``, and ``amount``.
@@ -171,9 +133,7 @@ class FragmentClient:
username: Recipient identifier — ``@username``, ``username``, or ``https://t.me/username``.
amount: Number of stars — integer from ``50`` to ``10 000 000``.
show_sender: Show your name as the gift sender. Defaults to ``True``.
payment_method: Payment currency — ``"ton"`` (GRAM (ex TON), default), ``"usdt_ton"`` (USDT on GRAM (ex TON)),
``"usdt_eth"``, ``"usdt_pol"``, ``"usdc_eth"``, ``"usdc_base"``,
or ``"usdc_pol"``.
payment_method: Payment currency — defaults to ``PaymentMethod.GRAM``.
Returns:
:class:`StarsResult` with ``transaction_id``, ``username``, and ``amount``.
@@ -197,8 +157,7 @@ class FragmentClient:
"""Add funds to your own Telegram Ads account.
Args:
account: Your Fragment Ads account identifier — the channel or bot username
the Ads account is linked to (e.g. ``"@mychannel"``).
account: Channel or bot username the Ads account is linked to (e.g. ``"@mychannel"``).
amount: Amount in GRAM (ex TON) — integer from ``1`` to ``1 000 000 000``.
Returns:
@@ -210,9 +169,7 @@ class FragmentClient:
"""Return the address, state, and balances of the wallet.
Returns:
:class:`WalletInfo` with ``address`` (``"UQ..."``), ``state``
(``"active"``, ``"uninit"``, ``"nonexist"``, or ``"frozen"``),
``gram_balance`` in GRAM (ex TON), and ``usdt_balance`` in USDT.
:class:`WalletInfo` with ``address``, ``state``, ``gram_balance``, and ``usdt_balance``.
"""
return await self.tonapi.get_wallet()
@@ -229,13 +186,10 @@ class FragmentClient:
channel: Channel identifier — ``@channel``, ``channel``, or ``https://t.me/channel``.
winners: Number of winners — integer from ``1`` to ``15``.
amount: Stars each winner receives — integer from ``500`` to ``1 000 000``.
payment_method: Payment currency — ``"ton"`` (GRAM (ex TON), default), ``"usdt_ton"`` (USDT on GRAM (ex TON)),
``"usdt_eth"``, ``"usdt_pol"``, ``"usdc_eth"``, ``"usdc_base"``,
or ``"usdc_pol"``.
payment_method: Payment currency — defaults to ``PaymentMethod.GRAM``.
Returns:
:class:`StarsGiveawayResult` with ``transaction_id``, ``channel``,
``winners``, and ``amount``.
:class:`StarsGiveawayResult` with ``transaction_id``, ``channel``, ``winners``, and ``amount``.
"""
return await self.giveaways.giveaway_stars(channel, winners, amount, payment_method=payment_method)
@@ -252,13 +206,10 @@ class FragmentClient:
channel: Channel identifier — ``@channel``, ``channel``, or ``https://t.me/channel``.
winners: Number of winners — integer from ``1`` to ``24 000``.
months: Premium duration per winner — ``3``, ``6``, or ``12``. Defaults to ``3``.
payment_method: Payment currency — ``"ton"`` (GRAM (ex TON), default), ``"usdt_ton"`` (USDT on GRAM (ex TON)),
``"usdt_eth"``, ``"usdt_pol"``, ``"usdc_eth"``, ``"usdc_base"``,
or ``"usdc_pol"``.
payment_method: Payment currency — defaults to ``PaymentMethod.GRAM``.
Returns:
:class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``,
``winners``, and ``amount``.
:class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``, ``winners``, and ``amount``.
"""
return await self.giveaways.giveaway_premium(channel, winners, months, payment_method=payment_method)
@@ -266,7 +217,7 @@ class FragmentClient:
"""Fetch the current pending login code for an anonymous number.
Args:
number: Phone number with or without leading ``+`` (e.g. ``"+1234567890"``).
number: Phone number with or without leading ``+``.
Returns:
:class:`LoginCodeResult` with ``number``, ``code`` (``None`` if none pending),
@@ -293,7 +244,7 @@ class FragmentClient:
:class:`TerminateSessionsResult` with ``number`` and ``message``.
Raises:
AnonymousNumberError: If the number is not owned by this account or has no active sessions.
AnonymousNumberError: If the number is not owned or has no active sessions.
"""
return await self.anonymous_numbers.terminate_sessions(number)
@@ -307,17 +258,13 @@ class FragmentClient:
"""Search the Fragment marketplace for Telegram usernames.
Args:
query: Search text (e.g. ``"durov"``). Omit or pass ``""`` to browse all.
sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or
``"ending"``. Omit to use Fragment's default ordering.
filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or
``""`` (available items). Omit to return all.
offset_id: Pagination cursor — pass :attr:`UsernamesResult.next_offset_id`
from a previous result to fetch the next page.
query: Search text. Omit or pass ``""`` to browse all.
sort: ``"price_desc"``, ``"price_asc"``, ``"listed"``, or ``"ending"``.
filter: ``"auction"``, ``"sale"``, ``"sold"``, or ``""`` (available).
offset_id: Pass :attr:`UsernamesResult.next_offset_id` to fetch the next page.
Returns:
:class:`UsernamesResult` with ``items`` (parsed list of item dicts)
and ``next_offset_id`` (``None`` on the last page).
:class:`UsernamesResult` with ``items`` and ``next_offset_id``.
"""
return await self.marketplace.search_usernames(query, sort=sort, filter=filter, offset_id=offset_id)
@@ -331,17 +278,13 @@ class FragmentClient:
"""Search the Fragment marketplace for anonymous Telegram numbers.
Args:
query: Search text (e.g. ``"888"``). Omit or pass ``""`` to browse all.
sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or
``"ending"``. Omit to use Fragment's default ordering.
filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or
``""`` (available items). Omit to return all.
offset_id: Pagination cursor — pass :attr:`NumbersResult.next_offset_id`
from a previous result to fetch the next page.
query: Search text. Omit or pass ``""`` to browse all.
sort: ``"price_desc"``, ``"price_asc"``, ``"listed"``, or ``"ending"``.
filter: ``"auction"``, ``"sale"``, ``"sold"``, or ``""`` (available).
offset_id: Pass :attr:`NumbersResult.next_offset_id` to fetch the next page.
Returns:
:class:`NumbersResult` with ``items`` (parsed list of item dicts)
and ``next_offset_id`` (``None`` on the last page).
:class:`NumbersResult` with ``items`` and ``next_offset_id``.
"""
return await self.marketplace.search_numbers(query, sort=sort, filter=filter, offset_id=offset_id)
@@ -358,22 +301,16 @@ class FragmentClient:
"""Search the Fragment gifts marketplace.
Args:
query: Search text. Omit or pass ``""`` to browse without filtering by name.
collection: Filter by gift collection slug (e.g. ``"artisanbrick"``). Omit for all.
sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or
``"ending"``. Omit to use Fragment's default ordering.
filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or
``""`` (available items). Omit to return all.
view: Active attribute tab name (e.g. ``"Model"``, ``"Backdrop"``). Omit for default.
attr: Attribute filters — mapping of trait name to accepted values, e.g.
``{"Model": ["Foosball"], "Backdrop": ["Celtic Blue", "Orange"]}``.
Each key is sent as ``attr[Key]`` with its list of values.
offset: Integer page offset from a previous :class:`GiftsResult`.
Pass ``next_offset`` to fetch the next page.
query: Search text. Omit or pass ``""`` to browse all.
collection: Gift collection slug (e.g. ``"artisanbrick"``).
sort: ``"price_desc"``, ``"price_asc"``, ``"listed"``, or ``"ending"``.
filter: ``"auction"``, ``"sale"``, ``"sold"``, or ``""`` (available).
view: Active attribute tab name (e.g. ``"Model"``).
attr: Attribute filters — e.g. ``{"Model": ["Foosball"], "Backdrop": ["Celtic Blue"]}``.
offset: Pass :attr:`GiftsResult.next_offset` to fetch the next page.
Returns:
:class:`GiftsResult` with ``items`` (parsed list of item dicts)
and ``next_offset`` (``None`` on the last page).
:class:`GiftsResult` with ``items`` and ``next_offset``.
"""
return await self.marketplace.search_gifts(
query, collection=collection, sort=sort, filter=filter, view=view, attr=attr, offset=offset
@@ -384,24 +321,12 @@ class FragmentClient:
) -> dict[str, Any]:
"""Send a raw request to the Fragment API.
Useful for accessing undocumented or future Fragment API methods
without waiting for a library update.
Args:
method: Fragment API method name, e.g. ``"searchPremiumGiftRecipient"``.
data: Additional form-data fields to include in the request body.
page_url: Fragment page URL used to derive the API hash and headers.
Defaults to ``FRAGMENT_BASE_URL`` (``"https://fragment.com"``).
data: Additional form-data fields.
page_url: Fragment page URL to derive the API hash. Defaults to ``FRAGMENT_BASE_URL``.
Returns:
Raw parsed JSON response as a dict.
Example::
result = await client.call(
"searchPremiumGiftRecipient",
{"query": "@username", "months": 3},
page_url="https://fragment.com/premium/gift",
)
"""
return await raw_api_call(self.cookies, self.timeout, method, data, page_url, self.headers)
-3
View File
@@ -82,6 +82,3 @@ PREMIUM_MONTHS_VALID: frozenset[int] = frozenset({3, 6, 12})
# Mnemonic phrase valid word counts
MNEMONIC_WORD_COUNTS_VALID: frozenset[int] = frozenset({12, 24})
# Tonapi API key minimum length (tonapi.io)
TONAPI_KEY_MIN_LENGTH: int = 68
+58
View File
@@ -0,0 +1,58 @@
from __future__ import annotations
import json
from typing import Any, cast
from pyfragment.core.constants import MNEMONIC_WORD_COUNTS_VALID, REQUIRED_COOKIE_KEYS
from pyfragment.enums import ApiProvider, WalletVersion
from pyfragment.exceptions import ConfigurationError, CookieError
def parse_cookies(cookies: dict[str, Any] | str) -> dict[str, Any]:
if isinstance(cookies, str):
try:
cookies = json.loads(cookies)
except Exception as exc:
raise CookieError(CookieError.READ_FAILED.format(exc=exc)) from exc
return cast(dict[str, Any], cookies)
def validate_cookie_keys(cookies: dict[str, Any]) -> None:
missing = [k for k in REQUIRED_COOKIE_KEYS if not str(cookies.get(k, "")).strip()]
if missing:
raise CookieError(CookieError.MISSING_KEYS.format(keys=", ".join(missing)))
def normalize_provider(api_provider: str) -> ApiProvider:
try:
return ApiProvider(api_provider.strip().lower())
except ValueError:
raise ConfigurationError(
ConfigurationError.UNSUPPORTED_PROVIDER.format(
provider=api_provider,
supported=", ".join(sorted(p.value for p in ApiProvider)),
)
)
def normalize_wallet_version(wallet_version: str) -> WalletVersion:
version = wallet_version.strip().upper()
try:
return WalletVersion(version)
except ValueError:
raise ConfigurationError(
ConfigurationError.UNSUPPORTED_VERSION.format(
version=version,
supported=", ".join(sorted(m.value for m in WalletVersion)),
)
)
def validate_credentials(seed: str, api_key: str) -> None:
missing = [name for name, val in (("seed", seed), ("api_key", api_key)) if not val or not str(val).strip()]
if missing:
raise ConfigurationError(ConfigurationError.MISSING_VARS.format(keys=", ".join(missing)))
word_count = len(seed.split())
if word_count not in MNEMONIC_WORD_COUNTS_VALID:
raise ConfigurationError(ConfigurationError.INVALID_MNEMONIC.format(count=word_count))
+2 -2
View File
@@ -6,9 +6,9 @@ from typing import TYPE_CHECKING
from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE_INFO, GRAM_TOPUP_MAX, GRAM_TOPUP_MIN
from pyfragment.domains.ads.models import AdsRechargeResult
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.services.tonapi.account import get_account_info
from pyfragment.services.tonapi.transaction import process_transaction
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
+2 -2
View File
@@ -7,8 +7,6 @@ from typing import TYPE_CHECKING
from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE_INFO, GRAM_TOPUP_MAX, GRAM_TOPUP_MIN
from pyfragment.domains.ads.models import AdsTopupResult
from pyfragment.domains.payments import parse_required_payment_amount
from pyfragment.domains.tonapi.account import get_account_info
from pyfragment.domains.tonapi.transaction import process_transaction
from pyfragment.exceptions import (
ConfigurationError,
FragmentAPIError,
@@ -17,6 +15,8 @@ from pyfragment.exceptions import (
UserNotFoundError,
VerificationError,
)
from pyfragment.services.tonapi.account import get_account_info
from pyfragment.services.tonapi.transaction import process_transaction
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
+2 -2
View File
@@ -19,8 +19,6 @@ from pyfragment.core.constants import (
)
from pyfragment.domains.giveaways.models import PremiumGiveawayResult, StarsGiveawayResult
from pyfragment.domains.payments import parse_required_payment_amount
from pyfragment.domains.tonapi.account import get_account_info
from pyfragment.domains.tonapi.transaction import process_transaction
from pyfragment.enums import PaymentMethod
from pyfragment.exceptions import (
ConfigurationError,
@@ -30,6 +28,8 @@ from pyfragment.exceptions import (
UserNotFoundError,
VerificationError,
)
from pyfragment.services.tonapi.account import get_account_info
from pyfragment.services.tonapi.transaction import process_transaction
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
+2 -2
View File
@@ -15,8 +15,6 @@ from pyfragment.core.constants import (
)
from pyfragment.domains.payments import parse_required_payment_amount
from pyfragment.domains.purchases.models import PremiumResult, StarsResult
from pyfragment.domains.tonapi.account import get_account_info
from pyfragment.domains.tonapi.transaction import process_transaction
from pyfragment.enums import PaymentMethod
from pyfragment.exceptions import (
AlreadySubscribedError,
@@ -27,6 +25,8 @@ from pyfragment.exceptions import (
UserNotFoundError,
VerificationError,
)
from pyfragment.services.tonapi.account import get_account_info
from pyfragment.services.tonapi.transaction import process_transaction
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
-5
View File
@@ -1,5 +0,0 @@
from pyfragment.domains.tonapi.service import TonapiService
__all__ = [
"TonapiService",
]
+5
View File
@@ -41,6 +41,11 @@ WALLET_CLASSES: dict[WalletVersion, Any] = {
}
class ApiProvider(StrEnum):
TONAPI = "tonapi" # tonconsole.com — default
TONCENTER = "toncenter" # t.me/toncenter
class SupportedBrowser(StrEnum):
ARC = "arc"
BRAVE = "brave"
+1 -5
View File
@@ -13,7 +13,6 @@ from pyfragment.core.constants import (
STARS_PURCHASE_MIN,
STARS_WINNERS_MAX,
STARS_WINNERS_MIN,
TONAPI_KEY_MIN_LENGTH,
)
@@ -31,10 +30,7 @@ class ConfigurationError(ClientError):
MISSING_VARS = "Missing required parameter(s): {keys}."
UNSUPPORTED_VERSION = "Unsupported wallet version '{version}'. Supported values: {supported}."
INVALID_MNEMONIC = f"Invalid mnemonic phrase: expected {', '.join(str(n) for n in sorted(MNEMONIC_WORD_COUNTS_VALID))} words, got {{count}}."
INVALID_API_KEY = (
f"Invalid Tonapi API key: expected at least {TONAPI_KEY_MIN_LENGTH} characters, got {{length}}. "
"Get a key at https://tonconsole.com."
)
UNSUPPORTED_PROVIDER = "Unsupported API provider '{provider}'. Supported values: {supported}."
INVALID_MONTHS = f"Invalid Premium duration: choose {', '.join(str(m) for m in sorted(PREMIUM_MONTHS_VALID))} months."
INVALID_STARS_AMOUNT = (
f"Invalid Stars amount: must be an integer between {STARS_PURCHASE_MIN:,} and {STARS_PURCHASE_MAX:,}."
+5
View File
@@ -0,0 +1,5 @@
from pyfragment.services.tonapi.service import TonapiService
__all__ = [
"TonapiService",
]
@@ -5,14 +5,14 @@ import logging
from typing import TYPE_CHECKING, Any
from ton_core import NetworkGlobalID
from tonutils.clients import TonapiClient
from tonutils.clients import TonapiClient, ToncenterClient
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_GRAM_BALANCE, MIN_USDT_BALANCE, USDT_GRAM_MASTER_ADDRESS
from pyfragment.domains.tonapi.models import WalletInfo
from pyfragment.enums import WALLET_CLASSES
from pyfragment.enums import WALLET_CLASSES, ApiProvider
from pyfragment.exceptions import WalletError
from pyfragment.services.tonapi.models import WalletInfo
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
@@ -21,6 +21,13 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
def _make_ton_client(client: FragmentClient) -> Any:
"""Return the appropriate tonutils client based on the configured api_provider."""
if client.api_provider == ApiProvider.TONCENTER:
return ToncenterClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key)
return TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key)
async def get_usdt_balance(ton: Any, wallet_address: str) -> float:
"""Return the USDT balance for a Fragment-linked GRAM (ex TON) wallet."""
try:
@@ -92,7 +99,7 @@ async def check_usdt_payment_balance(
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:
async with _make_ton_client(client) as ton:
try:
wallet_cls = WALLET_CLASSES[client.wallet_version]
wallet, pub_key, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
@@ -110,7 +117,7 @@ async def get_account_info(client: FragmentClient) -> dict[str, Any]:
async def get_wallet_info(client: FragmentClient) -> WalletInfo:
"""Fetch the wallet address, chain state, and GRAM (ex TON)/USDT balances."""
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
async with _make_ton_client(client) as ton:
try:
wallet_cls = WALLET_CLASSES[client.wallet_version]
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
@@ -3,8 +3,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from pyfragment.domains.base import BaseService
from pyfragment.domains.tonapi.account import get_wallet_info
from pyfragment.domains.tonapi.models import WalletInfo
from pyfragment.services.tonapi.account import get_wallet_info
from pyfragment.services.tonapi.models import WalletInfo
if TYPE_CHECKING:
pass
@@ -7,13 +7,12 @@ import random
import ssl
from typing import TYPE_CHECKING, Any
from ton_core import Cell, NetworkGlobalID
from tonutils.clients import TonapiClient
from ton_core import Cell
from tonutils.exceptions import ProviderResponseError
from pyfragment.domains.tonapi.account import check_gram_payment_balance, check_usdt_payment_balance
from pyfragment.enums import WALLET_CLASSES, PaymentMethod
from pyfragment.exceptions import ParseError, TransactionError, WalletError
from pyfragment.services.tonapi.account import _make_ton_client, check_gram_payment_balance, check_usdt_payment_balance
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
@@ -141,7 +140,7 @@ async def process_transaction(
message = _extract_message(transaction_data)
amount_gram = int(message["amount"]) / 1_000_000_000
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
async with _make_ton_client(client) as ton:
wallet_cls = WALLET_CLASSES[client.wallet_version]
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
+4 -4
View File
@@ -8,7 +8,7 @@ import pytest
from ton_core import Cell
from pyfragment import ParseError
from pyfragment.domains.tonapi.transaction import clean_decode
from pyfragment.services.tonapi.transaction import clean_decode
PAYLOAD_CASES = [
pytest.param(
@@ -86,7 +86,7 @@ def test_decode_payload_accepts_base64url_alphabet() -> None:
raw = b"\xfb\xef\xff\x00"
payload = base64.urlsafe_b64encode(raw).decode().rstrip("=")
with patch("pyfragment.domains.tonapi.transaction.Cell.one_from_boc", return_value=_FakeCell()) as mocked:
with patch("pyfragment.services.tonapi.transaction.Cell.one_from_boc", return_value=_FakeCell()) as mocked:
result = clean_decode(payload)
mocked.assert_called_once_with(raw)
@@ -106,7 +106,7 @@ def test_clean_decode_returns_text_comment_when_utf8() -> None:
return _FakeSlice()
payload = base64.urlsafe_b64encode(b"\x00\x01").decode().rstrip("=")
with patch("pyfragment.domains.tonapi.transaction.Cell.one_from_boc", return_value=_FakeCell()):
with patch("pyfragment.services.tonapi.transaction.Cell.one_from_boc", return_value=_FakeCell()):
parsed = clean_decode(payload)
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("=")
fake_cell: object = _FakeCell()
with patch("pyfragment.domains.tonapi.transaction.Cell.one_from_boc", return_value=fake_cell):
with patch("pyfragment.services.tonapi.transaction.Cell.one_from_boc", return_value=fake_cell):
parsed = clean_decode(payload)
assert parsed is fake_cell
+1 -6
View File
@@ -5,7 +5,7 @@ import json
import pytest
from pyfragment import ConfigurationError, CookieError, FragmentClient
from pyfragment.core.constants import MNEMONIC_WORD_COUNTS_VALID, TONAPI_KEY_MIN_LENGTH
from pyfragment.core.constants import MNEMONIC_WORD_COUNTS_VALID
from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED
# Client init tests
@@ -70,11 +70,6 @@ def test_missing_api_key_raises() -> None:
FragmentClient(seed=VALID_SEED, api_key="", cookies=VALID_COOKIES)
def test_short_api_key_raises() -> None:
with pytest.raises(ConfigurationError):
FragmentClient(seed=VALID_SEED, api_key="A" * (TONAPI_KEY_MIN_LENGTH - 1), cookies=VALID_COOKIES)
# Cookie validation tests
+13 -12
View File
@@ -8,8 +8,8 @@ import pytest
from tonutils.exceptions import ProviderResponseError
from pyfragment import TransactionError, WalletError
from pyfragment.domains.tonapi.transaction import process_transaction
from pyfragment.enums import PaymentMethod
from pyfragment.services.tonapi.transaction import process_transaction
from tests.shared import VALID_SEED
@@ -48,12 +48,13 @@ def _make_wallet(balance_nanotons: int) -> MagicMock:
@contextmanager
def _patch_wallet(wallet: MagicMock) -> Generator[None, None, None]:
mock_ton_ctx = MagicMock()
mock_ton_ctx.__aenter__ = AsyncMock(return_value=MagicMock())
mock_ton_ctx.__aexit__ = AsyncMock(return_value=False)
with (
patch("pyfragment.domains.tonapi.transaction.TonapiClient") as mock_tonapi,
patch("pyfragment.domains.tonapi.transaction.WALLET_CLASSES") as mock_classes,
patch("pyfragment.services.tonapi.transaction._make_ton_client", return_value=mock_ton_ctx),
patch("pyfragment.services.tonapi.transaction.WALLET_CLASSES") as mock_classes,
):
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
mock_classes["V5R1"].from_mnemonic.return_value = (wallet, MagicMock(), None, None)
yield
@@ -64,7 +65,7 @@ def _patch_wallet(wallet: MagicMock) -> Generator[None, None, None]:
@pytest.mark.asyncio
async def test_sufficient_balance_broadcasts() -> None:
wallet = _make_wallet(balance_nanotons=1_000_000_000) # 1 GRAM, above threshold
with _patch_wallet(wallet), patch("pyfragment.domains.tonapi.transaction.clean_decode", return_value="50 Telegram Stars"):
with _patch_wallet(wallet), patch("pyfragment.services.tonapi.transaction.clean_decode", return_value="50 Telegram Stars"):
result = await process_transaction(_make_client(), TRANSACTION_DATA)
assert result == "abc123"
wallet.transfer.assert_called_once()
@@ -82,7 +83,7 @@ async def test_insufficient_balance_raises() -> None:
@pytest.mark.asyncio
async def test_exact_minimum_balance_broadcasts() -> None:
wallet = _make_wallet(balance_nanotons=500_000_000) # exactly transaction amount threshold
with _patch_wallet(wallet), patch("pyfragment.domains.tonapi.transaction.clean_decode", return_value="50 Telegram Stars"):
with _patch_wallet(wallet), patch("pyfragment.services.tonapi.transaction.clean_decode", return_value="50 Telegram Stars"):
result = await process_transaction(_make_client(), TRANSACTION_DATA)
assert result == "abc123"
@@ -124,7 +125,7 @@ async def test_balance_check_failed_raises_wallet_error() -> None:
async def test_rate_limit_retries_and_succeeds() -> None:
wallet = _make_wallet(balance_nanotons=1_000_000_000)
wallet.transfer = AsyncMock(side_effect=[_provider_error(429, "rate limited"), MagicMock(normalized_hash="abc123")])
with _patch_wallet(wallet), patch("pyfragment.domains.tonapi.transaction.clean_decode", return_value=""):
with _patch_wallet(wallet), patch("pyfragment.services.tonapi.transaction.clean_decode", return_value=""):
result = await process_transaction(_make_client(), TRANSACTION_DATA)
assert result == "abc123"
assert wallet.transfer.call_count == 2
@@ -135,7 +136,7 @@ async def test_duplicate_seqno_raises_after_retries() -> None:
wallet = _make_wallet(balance_nanotons=1_000_000_000)
err = _provider_error(406, "Duplicate msg_seqno")
wallet.transfer = AsyncMock(side_effect=[err, err, err])
with _patch_wallet(wallet), patch("pyfragment.domains.tonapi.transaction.clean_decode", return_value=""):
with _patch_wallet(wallet), patch("pyfragment.services.tonapi.transaction.clean_decode", return_value=""):
with pytest.raises(TransactionError, match="seqno"):
await process_transaction(_make_client(), TRANSACTION_DATA)
assert wallet.transfer.call_count == 3
@@ -144,7 +145,7 @@ async def test_duplicate_seqno_raises_after_retries() -> None:
@pytest.mark.asyncio
async def test_usdt_payment_requires_min_gram_gas_reserve() -> None:
wallet = _make_wallet(balance_nanotons=10_000_000) # 0.01 GRAM below MIN_GRAM_BALANCE
with _patch_wallet(wallet), patch("pyfragment.domains.tonapi.account.get_usdt_balance", AsyncMock(return_value=100.0)):
with _patch_wallet(wallet), patch("pyfragment.services.tonapi.account.get_usdt_balance", AsyncMock(return_value=100.0)):
with pytest.raises(WalletError, match="Insufficient GRAM"):
await process_transaction(_make_client(), TRANSACTION_DATA, payment_method=PaymentMethod.USDT_GRAM)
@@ -167,8 +168,8 @@ 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.account.get_usdt_balance", AsyncMock(return_value=5.0)),
patch("pyfragment.services.tonapi.transaction.clean_decode", return_value=""),
patch("pyfragment.services.tonapi.account.get_usdt_balance", AsyncMock(return_value=5.0)),
):
with pytest.raises(WalletError, match="Insufficient USDT balance"):
await process_transaction(
+8 -8
View File
@@ -19,12 +19,12 @@ 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.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)),
patch("pyfragment.services.tonapi.account._make_ton_client") as mock_tonapi,
patch("pyfragment.services.tonapi.account.WALLET_CLASSES") as mock_classes,
patch("pyfragment.services.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)
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False) # _make_ton_client returns context manager
mock_classes["V5R1"].from_mnemonic.return_value = (mock_wallet, MagicMock(), None, None)
result = await client.get_wallet()
@@ -45,12 +45,12 @@ 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.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)),
patch("pyfragment.services.tonapi.account._make_ton_client") as mock_tonapi,
patch("pyfragment.services.tonapi.account.WALLET_CLASSES") as mock_classes,
patch("pyfragment.services.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)
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False) # _make_ton_client returns context manager
mock_classes["V5R1"].from_mnemonic.return_value = (mock_wallet, MagicMock(), None, None)
result = await client.get_wallet()
+2 -2
View File
@@ -10,8 +10,8 @@ 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.account # noqa: F401
import pyfragment.domains.tonapi.transaction # noqa: F401
import pyfragment.services.tonapi.account # noqa: F401
import pyfragment.services.tonapi.transaction # noqa: F401
from pyfragment import FragmentClient
from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED