From 228fbfcd1cfe4327405da1a9fb3660e4320f38fd Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Tue, 16 Jun 2026 01:37:26 +0300 Subject: [PATCH] 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. --- CHANGELOG.md | 4 + README.md | 19 +- examples/auctions/search_gifts.py | 10 +- examples/auctions/search_numbers.py | 10 +- examples/auctions/search_usernames.py | 10 +- examples/client/raw_api_call.py | 10 +- examples/client/wallet_info.py | 6 +- examples/numbers/manage_number.py | 10 +- examples/purchase/premium_giveaway.py | 10 +- examples/purchase/purchase_premium.py | 10 +- examples/purchase/purchase_stars.py | 10 +- examples/purchase/recharge_ads_balance.py | 10 +- examples/purchase/stars_giveaway.py | 10 +- examples/purchase/topup_gram_balance.py | 10 +- pyfragment/__init__.py | 5 +- pyfragment/client.py | 183 ++++++------------ pyfragment/core/constants.py | 3 - pyfragment/core/validation.py | 58 ++++++ pyfragment/domains/ads/recharge.py | 4 +- pyfragment/domains/ads/tonup.py | 4 +- pyfragment/domains/giveaways/giveaway.py | 4 +- pyfragment/domains/purchases/purchase.py | 4 +- pyfragment/domains/tonapi/__init__.py | 5 - pyfragment/enums.py | 5 + pyfragment/exceptions.py | 6 +- pyfragment/services/tonapi/__init__.py | 5 + .../{domains => services}/tonapi/account.py | 17 +- .../{domains => services}/tonapi/models.py | 0 .../{domains => services}/tonapi/service.py | 4 +- .../tonapi/transaction.py | 7 +- tests/001_test_decode.py | 8 +- tests/002_test_client.py | 7 +- tests/003_test_balance.py | 25 +-- tests/007_test_wallet.py | 16 +- tests/conftest.py | 4 +- 35 files changed, 286 insertions(+), 227 deletions(-) create mode 100644 pyfragment/core/validation.py delete mode 100644 pyfragment/domains/tonapi/__init__.py create mode 100644 pyfragment/services/tonapi/__init__.py rename pyfragment/{domains => services}/tonapi/account.py (89%) rename pyfragment/{domains => services}/tonapi/models.py (100%) rename pyfragment/{domains => services}/tonapi/service.py (69%) rename pyfragment/{domains => services}/tonapi/transaction.py (96%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71b9d2e..20d531d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` diff --git a/README.md b/README.md index 3bd9419..767377a 100644 --- a/README.md +++ b/README.md @@ -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 | --- diff --git a/examples/auctions/search_gifts.py b/examples/auctions/search_gifts.py index e0c78df..872f077 100644 --- a/examples/auctions/search_gifts.py +++ b/examples/auctions/search_gifts.py @@ -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):") diff --git a/examples/auctions/search_numbers.py b/examples/auctions/search_numbers.py index 9cc40e2..acc652c 100644 --- a/examples/auctions/search_numbers.py +++ b/examples/auctions/search_numbers.py @@ -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):") diff --git a/examples/auctions/search_usernames.py b/examples/auctions/search_usernames.py index b8c4ce3..15fe62a 100644 --- a/examples/auctions/search_usernames.py +++ b/examples/auctions/search_usernames.py @@ -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):") diff --git a/examples/client/raw_api_call.py b/examples/client/raw_api_call.py index 066e755..04dbe36 100644 --- a/examples/client/raw_api_call.py +++ b/examples/client/raw_api_call.py @@ -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) diff --git a/examples/client/wallet_info.py b/examples/client/wallet_info.py index 1b0de85..d14094a 100644 --- a/examples/client/wallet_info.py +++ b/examples/client/wallet_info.py @@ -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}") diff --git a/examples/numbers/manage_number.py b/examples/numbers/manage_number.py index b4b9702..632ed27 100644 --- a/examples/numbers/manage_number.py +++ b/examples/numbers/manage_number.py @@ -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: diff --git a/examples/purchase/premium_giveaway.py b/examples/purchase/premium_giveaway.py index 82cd91f..4869887 100644 --- a/examples/purchase/premium_giveaway.py +++ b/examples/purchase/premium_giveaway.py @@ -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, diff --git a/examples/purchase/purchase_premium.py b/examples/purchase/purchase_premium.py index a245c55..d4327d5 100644 --- a/examples/purchase/purchase_premium.py +++ b/examples/purchase/purchase_premium.py @@ -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, diff --git a/examples/purchase/purchase_stars.py b/examples/purchase/purchase_stars.py index ce9dc10..36ca65a 100644 --- a/examples/purchase/purchase_stars.py +++ b/examples/purchase/purchase_stars.py @@ -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, diff --git a/examples/purchase/recharge_ads_balance.py b/examples/purchase/recharge_ads_balance.py index a3fc005..d2a556e 100644 --- a/examples/purchase/recharge_ads_balance.py +++ b/examples/purchase/recharge_ads_balance.py @@ -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 # 1–1 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: diff --git a/examples/purchase/stars_giveaway.py b/examples/purchase/stars_giveaway.py index 338e973..5443023 100644 --- a/examples/purchase/stars_giveaway.py +++ b/examples/purchase/stars_giveaway.py @@ -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, diff --git a/examples/purchase/topup_gram_balance.py b/examples/purchase/topup_gram_balance.py index ee26860..0680dc8 100644 --- a/examples/purchase/topup_gram_balance.py +++ b/examples/purchase/topup_gram_balance.py @@ -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 # 1–1 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: diff --git a/pyfragment/__init__.py b/pyfragment/__init__.py index d30e5f3..449af9e 100644 --- a/pyfragment/__init__.py +++ b/pyfragment/__init__.py @@ -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", diff --git a/pyfragment/client.py b/pyfragment/client.py index 3999b15..b8abe17 100644 --- a/pyfragment/client.py +++ b/pyfragment/client.py @@ -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) diff --git a/pyfragment/core/constants.py b/pyfragment/core/constants.py index 835869e..40253fb 100644 --- a/pyfragment/core/constants.py +++ b/pyfragment/core/constants.py @@ -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 diff --git a/pyfragment/core/validation.py b/pyfragment/core/validation.py new file mode 100644 index 0000000..4e06706 --- /dev/null +++ b/pyfragment/core/validation.py @@ -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)) diff --git a/pyfragment/domains/ads/recharge.py b/pyfragment/domains/ads/recharge.py index ab44e79..9a35f56 100644 --- a/pyfragment/domains/ads/recharge.py +++ b/pyfragment/domains/ads/recharge.py @@ -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 diff --git a/pyfragment/domains/ads/tonup.py b/pyfragment/domains/ads/tonup.py index e4f69d8..559c60a 100644 --- a/pyfragment/domains/ads/tonup.py +++ b/pyfragment/domains/ads/tonup.py @@ -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 diff --git a/pyfragment/domains/giveaways/giveaway.py b/pyfragment/domains/giveaways/giveaway.py index dc5e9e6..436f2b2 100644 --- a/pyfragment/domains/giveaways/giveaway.py +++ b/pyfragment/domains/giveaways/giveaway.py @@ -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 diff --git a/pyfragment/domains/purchases/purchase.py b/pyfragment/domains/purchases/purchase.py index e774718..6061e46 100644 --- a/pyfragment/domains/purchases/purchase.py +++ b/pyfragment/domains/purchases/purchase.py @@ -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 diff --git a/pyfragment/domains/tonapi/__init__.py b/pyfragment/domains/tonapi/__init__.py deleted file mode 100644 index 42b4b85..0000000 --- a/pyfragment/domains/tonapi/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from pyfragment.domains.tonapi.service import TonapiService - -__all__ = [ - "TonapiService", -] diff --git a/pyfragment/enums.py b/pyfragment/enums.py index ac672e5..098f17b 100644 --- a/pyfragment/enums.py +++ b/pyfragment/enums.py @@ -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" diff --git a/pyfragment/exceptions.py b/pyfragment/exceptions.py index 5c1e765..9153d24 100644 --- a/pyfragment/exceptions.py +++ b/pyfragment/exceptions.py @@ -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:,}." diff --git a/pyfragment/services/tonapi/__init__.py b/pyfragment/services/tonapi/__init__.py new file mode 100644 index 0000000..b79666a --- /dev/null +++ b/pyfragment/services/tonapi/__init__.py @@ -0,0 +1,5 @@ +from pyfragment.services.tonapi.service import TonapiService + +__all__ = [ + "TonapiService", +] diff --git a/pyfragment/domains/tonapi/account.py b/pyfragment/services/tonapi/account.py similarity index 89% rename from pyfragment/domains/tonapi/account.py rename to pyfragment/services/tonapi/account.py index 7e094ae..ddaf9f0 100644 --- a/pyfragment/domains/tonapi/account.py +++ b/pyfragment/services/tonapi/account.py @@ -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) diff --git a/pyfragment/domains/tonapi/models.py b/pyfragment/services/tonapi/models.py similarity index 100% rename from pyfragment/domains/tonapi/models.py rename to pyfragment/services/tonapi/models.py diff --git a/pyfragment/domains/tonapi/service.py b/pyfragment/services/tonapi/service.py similarity index 69% rename from pyfragment/domains/tonapi/service.py rename to pyfragment/services/tonapi/service.py index 3de0067..e7de994 100644 --- a/pyfragment/domains/tonapi/service.py +++ b/pyfragment/services/tonapi/service.py @@ -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 diff --git a/pyfragment/domains/tonapi/transaction.py b/pyfragment/services/tonapi/transaction.py similarity index 96% rename from pyfragment/domains/tonapi/transaction.py rename to pyfragment/services/tonapi/transaction.py index 1e8bacf..5d9bec0 100644 --- a/pyfragment/domains/tonapi/transaction.py +++ b/pyfragment/services/tonapi/transaction.py @@ -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) diff --git a/tests/001_test_decode.py b/tests/001_test_decode.py index 9be867e..25310a0 100644 --- a/tests/001_test_decode.py +++ b/tests/001_test_decode.py @@ -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 diff --git a/tests/002_test_client.py b/tests/002_test_client.py index 91660ab..3bb76d5 100644 --- a/tests/002_test_client.py +++ b/tests/002_test_client.py @@ -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 diff --git a/tests/003_test_balance.py b/tests/003_test_balance.py index 6ac5f0b..91ef393 100644 --- a/tests/003_test_balance.py +++ b/tests/003_test_balance.py @@ -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( diff --git a/tests/007_test_wallet.py b/tests/007_test_wallet.py index 9f699c3..ea07fdc 100644 --- a/tests/007_test_wallet.py +++ b/tests/007_test_wallet.py @@ -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() diff --git a/tests/conftest.py b/tests/conftest.py index 94e7f63..d284b9a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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