diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2e97f59..9b9892e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -56,7 +56,7 @@ jobs: - run: uv build - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: dist path: dist/* diff --git a/CHANGELOG.md b/CHANGELOG.md index 5423c88..8317c4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,39 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI --- +## [2026.2.2] — 2026-05-11 + +### Added + +- `payment_method` option (`"ton"` / `"usdt_ton"`) for: + - `purchase_stars()` + - `purchase_premium()` + - `giveaway_stars()` + - `giveaway_premium()` + +### Changed + +- Added runtime validation for `payment_method` via `SUPPORTED_PAYMENT_METHODS` and `ConfigurationError.INVALID_PAYMENT_METHOD` +- Updated method docstrings to explicitly document recipient/channel formats: + - `@username` / `username` / `https://t.me/username` +- `get_wallet()` now returns balances as separate fields: `ton_balance` and `usdt_balance` +- Wallet/system test output now prints TON and USDT balances on separate lines +- Balance checks are now method-aware with explicit thresholds: + - `ton`: minimum TON balance threshold via `MIN_TON_BALANCE` (based on current 50 Stars purchase amount) + - `usdt_ton`: minimum USDT balance threshold via `MIN_USDT_BALANCE` (based on current 50 Stars purchase amount) + +### Tests + +- Extended stars and premium test suites to cover: + - invalid payment method + - payment method propagation to `init*Request` payloads + - accepted query formats (`@`, plain username, `t.me` link) +- Extended wallet tests to verify separate TON/USDT balance values in `WalletInfo` + +### Documentation + +- Simplified `README` usage example + ## [2026.2.1] — 2026-05-03 ### Fixed @@ -48,37 +81,44 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI ### Added **Giveaways** + - `giveaway_stars(channel, winners, amount)` — Stars giveaway; 1–5 winners, 500–1 000 000 stars each - `giveaway_premium(channel, winners, months)` — Premium giveaway; 1–24 000 winners, 3/6/12 months each - `StarsGiveawayResult`, `PremiumGiveawayResult` result types **Telegram Ads** + - `recharge_ads(account, amount)` — top up a Telegram Ads account; 1–1 000 000 000 TON - `AdsRechargeResult` result type **Marketplace** + - `search_usernames(query?, sort?, filter?, offset_id?)` — search Fragment usernames; `sort`: `price_desc / price_asc / listed / ending`, `filter`: `auction / sale / sold` - `search_numbers(query?, sort?, filter?, offset_id?)` — search Fragment anonymous numbers; same `sort` / `filter` / pagination semantics - `search_gifts(query?, collection?, sort?, filter?, view?, attr?, offset?)` — search Fragment gifts; `attr` accepts `{"Model": ["Foosball"], "Backdrop": ["Celtic Blue"]}` - `UsernamesResult`, `NumbersResult`, `GiftsResult` result types **Anonymous numbers** + - `get_login_code(number)` — fetch the current pending login code - `toggle_login_codes(number, can_receive)` — enable or disable login code delivery - `terminate_sessions(number)` — terminate all active Telegram sessions (two-step flow handled internally) - `LoginCodeResult`, `TerminateSessionsResult` result types; `AnonymousNumberError` exception **Raw API** + - `FragmentClient.call(method, data, *, page_url)` — raw request to any Fragment API method - `FRAGMENT_BASE_URL` constant — base URL shared across all page constants and headers **Examples** + - `examples/client/` — `wallet_info.py` (wallet info), `raw_api_call.py` (raw API call) - `examples/numbers/` — `manage_number.py` (login code fetch, session termination) - `examples/auctions/` — `search_usernames.py`, `search_numbers.py`, `search_gifts.py` (marketplace search with pagination) - `examples/purchase/` — `send_stars.py`, `send_premium.py`, `topup_ton_balance.py`, `run_stars_giveaway.py`, `run_premium_giveaway.py`, `recharge_ads_balance.py` ### Changed + - All result types now expose a unified `amount` field (`months` and `stars` removed) - `__repr__` includes the unit — `3 months`, `500 stars`, etc. - `timestamp` removed from all result dataclasses @@ -91,15 +131,18 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI ## [2026.0.2] — 2026-03-20 ### Added + - `timeout` parameter on `FragmentClient` (default `30.0` s) — passed through to every HTTP request ### Changed + - Cookie validation: narrowed type internally so no `# type: ignore` is needed in `FragmentClient.__init__` - `WALLET_CLASSES` typed as `dict[str, Any]` so mypy resolves `from_mnemonic` correctly - All four `examples/` files updated to `async with FragmentClient`, f-strings, and aligned error messages - README usage section rewritten with a single comprehensive `async with` example ### Fixed + - mypy: missing return path in `process_transaction` after retry loop - mypy: `cookies` union-attr error in `FragmentClient.__init__` @@ -108,6 +151,7 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI ## [2026.0.1] — 2026-03-16 ### Added + - Initial stable release of `pyfragment` - `FragmentClient` — async client for the Fragment.com API with context manager support (`async with`) - `purchase_premium(username, months)` — purchase Telegram Premium for any user (3, 6, or 12 months) @@ -119,6 +163,7 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI - `py.typed` marker — full PEP 561 typing support for type-checkers - `__repr__` on all result types for readable debug output +[2026.2.2]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.2.2 [2026.2.1]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.2.1 [2026.2.0]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.2.0 [2026.1.0]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.1.0 diff --git a/README.md b/README.md index bb94671..7677515 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,14 @@

Fragment API

- Async Python client for the Fragment API — a unified toolkit to manage Telegram assets: purchase Stars and Premium, top up TON and Ads balances, run giveaways, manage anonymous numbers, and explore the marketplace for usernames, numbers, and gifts. + Async Python client for the Fragment API. Buy Stars and Premium, top up TON and Ads balances, run giveaways, manage anonymous numbers, and search Fragment listings.

[![PyPI version](https://img.shields.io/pypi/v/pyfragment?style=flat&color=blue)](https://pypi.org/project/pyfragment/) -[![PyPI downloads](https://img.shields.io/pypi/dm/pyfragment?style=flat&color=brightgreen)](https://pypi.org/project/pyfragment/) +[![PyPI Downloads](https://static.pepy.tech/personalized-badge/pyfragment?period=total&units=INTERNATIONAL_SYSTEM&left_color=GREY&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/pyfragment) [![Python](https://img.shields.io/badge/Python-3.10+-3776AB?style=flat&logo=python&logoColor=white)](https://python.org) -[![License](https://img.shields.io/github/license/bohd4nx/pyfragment?style=flat&color=lightgrey)](LICENSE) -[![Stars](https://img.shields.io/github/stars/bohd4nx/pyfragment?style=flat&color=yellow)](https://github.com/bohd4nx/pyfragment/stargazers) -[![CI](https://img.shields.io/github/actions/workflow/status/bohd4nx/pyfragment/ci.yml?style=flat&label=tests&logo=github)](https://github.com/bohd4nx/pyfragment/actions) +[![Tests](https://img.shields.io/github/actions/workflow/status/bohd4nx/pyfragment/ci.yml?style=flat&label=tests&logo=github)](https://github.com/bohd4nx/pyfragment/actions) +[![License](https://img.shields.io/github/license/bohd4nx/pyfragment?style=flat&color=green)](https://github.com/bohd4nx/pyfragment/blob/master/LICENSE) [Report Bug](https://github.com/bohd4nx/pyfragment/issues) · [Request Feature](https://github.com/bohd4nx/pyfragment/issues) · [**Donate TON**](https://app.tonkeeper.com/transfer/UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5) @@ -75,22 +74,13 @@ Refresh when you get authentication errors. ```python import asyncio -from pyfragment import ( - FragmentClient, - FragmentError, # base — catches everything below - UserNotFoundError, # username doesn't exist on Fragment - WalletError, # insufficient balance or misconfiguration - CookieError, # cookies are missing or expired - TransactionError, # on-chain broadcast failed - ConfigurationError, # invalid argument (months, amount, etc.) - FragmentAPIError, # unexpected Fragment API response -) +from pyfragment import FragmentClient async def main() -> None: async with FragmentClient( - seed="word1 word2 ... word24", # 24-word TON wallet mnemonic - api_key="YOUR_TONAPI_KEY", # from tonconsole.com + seed="word1 word2 ... word24", + api_key="YOUR_TONAPI_KEY", cookies={ "stel_ssid": "...", "stel_dt": "...", @@ -98,40 +88,29 @@ async def main() -> None: "stel_ton_token": "...", }, ) as client: - try: - # Purchase 6 months of Telegram Premium - result = await client.purchase_premium("@username", months=6) - print(f"{result.amount} months of Premium successfully sent to {result.username} | tx: {result.transaction_id}") + wallet = await client.get_wallet() + print(f"Wallet: {wallet.address} | TON: {wallet.ton_balance} | USDT: {wallet.usdt_balance}") - # Purchase 500 Stars - result = await client.purchase_stars("@username", amount=500) - print(f"{result.amount} Stars successfully sent to {result.username} | tx: {result.transaction_id}") + recipient = "https://t.me/username" # also supports: @username, username - # Top up 10 TON to Telegram balance - # wallet must hold at least amount + ~0.056 TON for gas - result = await client.topup_ton("@username", amount=10) - print(f"{result.amount} TON successfully sent to {result.username} | tx: {result.transaction_id}") + stars = await client.purchase_stars(recipient, amount=500, payment_method="usdt_ton") + print(f"Stars sent: {stars.amount} to {stars.username} | tx: {stars.transaction_id}") - except UserNotFoundError: - print(f"User was not found on fragment.com — check the username and try again.") - except WalletError as e: - print(f"Wallet error — insufficient balance or misconfiguration: {e}") - except CookieError: - print("Authentication failed — session cookies are missing or expired. Refresh them and retry.") - except TransactionError as e: - print(f"Transaction failed to broadcast on-chain: {e}") - except ConfigurationError as e: - print(f"Invalid argument: {e}") - except FragmentAPIError as e: - print(f"Unexpected response from Fragment API: {e}") - except FragmentError as e: - # catch-all for any other pyfragment error - print(f"Unexpected error: {e}") + premium = await client.purchase_premium(recipient, months=6, payment_method="ton") + print(f"Premium sent: {premium.amount} months to {premium.username} | tx: {premium.transaction_id}") asyncio.run(main()) ``` +Full runnable examples: + +- https://github.com/bohd4nx/pyfragment/tree/master/examples + +Payload debug/decode helper (thanks): + +- https://ton-cell-abi-viewer.vercel.app/ + ---
diff --git a/examples/client/wallet_info.py b/examples/client/wallet_info.py index 5ac628f..bfc6620 100644 --- a/examples/client/wallet_info.py +++ b/examples/client/wallet_info.py @@ -1,5 +1,5 @@ """ -Example: fetch wallet address, state, and balance. +Example: fetch wallet address, state, and separate 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. @@ -35,7 +35,8 @@ async def main() -> None: wallet = await client.get_wallet() print(f"Address: {wallet.address}") print(f"State: {wallet.state}") - print(f"Balance: {wallet.balance} TON") + print(f"Balance: {wallet.ton_balance} TON") + print(f"Balance: {wallet.usdt_balance} USDT") if __name__ == "__main__": diff --git a/examples/purchase/recharge_ads_balance.py b/examples/purchase/recharge_ads_balance.py index d0084bc..5e4c457 100644 --- a/examples/purchase/recharge_ads_balance.py +++ b/examples/purchase/recharge_ads_balance.py @@ -2,7 +2,7 @@ Example: recharge your own Telegram Ads account with TON. Amount must be an integer between 1 and 1 000 000 000 TON. -Your wallet must hold at least the recharge amount + ~0.056 TON for gas. +Your wallet must satisfy the current minimum TON threshold and transaction cost. """ import asyncio diff --git a/examples/purchase/run_premium_giveaway.py b/examples/purchase/run_premium_giveaway.py index d0a39b8..9fb8565 100644 --- a/examples/purchase/run_premium_giveaway.py +++ b/examples/purchase/run_premium_giveaway.py @@ -3,6 +3,8 @@ Example: run a Telegram Premium giveaway for a channel. winners must be an integer between 1 and 24 000. months (Premium duration per winner) must be 3, 6, or 12. +payment_method can be "ton" or "usdt_ton". +Channel can be "@channel", "channel", or "https://t.me/channel". """ import asyncio @@ -24,15 +26,21 @@ COOKIES = { "stel_ton_token": "YOUR_STEL_TON_TOKEN", } -CHANNEL = "@channel" +CHANNEL = "https://t.me/channel" WINNERS = 10 # 1–24 000 MONTHS = 3 # 3, 6 or 12 +PAYMENT_METHOD = "ton" # "ton" or "usdt_ton" async def main() -> None: async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: try: - result = await client.giveaway_premium(CHANNEL, winners=WINNERS, months=MONTHS) + result = await client.giveaway_premium( + CHANNEL, + winners=WINNERS, + months=MONTHS, + payment_method=PAYMENT_METHOD, + ) except UserNotFoundError: print(f"Channel {CHANNEL} was not found on fragment.com — check the username and try again.") return diff --git a/examples/purchase/run_stars_giveaway.py b/examples/purchase/run_stars_giveaway.py index 5a73310..7f98ed9 100644 --- a/examples/purchase/run_stars_giveaway.py +++ b/examples/purchase/run_stars_giveaway.py @@ -3,6 +3,8 @@ Example: run a Telegram Stars giveaway for a channel. winners must be an integer between 1 and 5. amount (stars per winner) must be an integer between 500 and 1 000 000. +payment_method can be "ton" or "usdt_ton". +Channel can be "@channel", "channel", or "https://t.me/channel". """ import asyncio @@ -24,15 +26,21 @@ COOKIES = { "stel_ton_token": "YOUR_STEL_TON_TOKEN", } -CHANNEL = "@channel" +CHANNEL = "https://t.me/channel" WINNERS = 3 # 1–5 AMOUNT = 1000 # 500–1 000 000 stars per winner +PAYMENT_METHOD = "usdt_ton" # "ton" or "usdt_ton" async def main() -> None: async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: try: - result = await client.giveaway_stars(CHANNEL, winners=WINNERS, amount=AMOUNT) + result = await client.giveaway_stars( + CHANNEL, + winners=WINNERS, + amount=AMOUNT, + payment_method=PAYMENT_METHOD, + ) except UserNotFoundError: print(f"Channel {CHANNEL} was not found on fragment.com — check the username and try again.") return diff --git a/examples/purchase/send_premium.py b/examples/purchase/send_premium.py index f82f1ed..a28cfa7 100644 --- a/examples/purchase/send_premium.py +++ b/examples/purchase/send_premium.py @@ -3,6 +3,8 @@ Example: purchase Telegram Premium for a user. Supported durations: 3, 6, or 12 months. Set show_sender=False to send anonymously. +payment_method can be "ton" or "usdt_ton". +Username can be "@username", "username", or "https://t.me/username". """ import asyncio @@ -24,14 +26,20 @@ COOKIES = { "stel_ton_token": "YOUR_STEL_TON_TOKEN", } -USERNAME = "@username" +USERNAME = "https://t.me/username" MONTHS = 3 # 3, 6 or 12 +PAYMENT_METHOD = "ton" # "ton" or "usdt_ton" async def main() -> None: async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: try: - result = await client.purchase_premium(USERNAME, months=MONTHS, show_sender=True) + result = await client.purchase_premium( + USERNAME, + months=MONTHS, + show_sender=True, + payment_method=PAYMENT_METHOD, + ) except UserNotFoundError: print(f"User {USERNAME} was not found on fragment.com — check the username and try again.") return diff --git a/examples/purchase/send_stars.py b/examples/purchase/send_stars.py index 76b616a..dbe5664 100644 --- a/examples/purchase/send_stars.py +++ b/examples/purchase/send_stars.py @@ -3,6 +3,8 @@ Example: purchase Telegram Stars for a user. Amount must be an integer between 50 and 1 000 000. Set show_sender=False to send anonymously. +payment_method can be "ton" or "usdt_ton". +Username can be "@username", "username", or "https://t.me/username". """ import asyncio @@ -24,14 +26,20 @@ COOKIES = { "stel_ton_token": "YOUR_STEL_TON_TOKEN", } -USERNAME = "@username" +USERNAME = "https://t.me/username" AMOUNT = 500 # 50–1 000 000 stars +PAYMENT_METHOD = "usdt_ton" # "ton" or "usdt_ton" async def main() -> None: async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: try: - result = await client.purchase_stars(USERNAME, amount=AMOUNT, show_sender=True) + result = await client.purchase_stars( + USERNAME, + amount=AMOUNT, + show_sender=True, + payment_method=PAYMENT_METHOD, + ) except UserNotFoundError: print(f"User {USERNAME} was not found on fragment.com — check the username and try again.") return diff --git a/examples/purchase/topup_ton_balance.py b/examples/purchase/topup_ton_balance.py index f09d0f3..c4fa58f 100644 --- a/examples/purchase/topup_ton_balance.py +++ b/examples/purchase/topup_ton_balance.py @@ -4,7 +4,7 @@ Example: top up TON to a recipient's Telegram balance. For adding TON to a Telegram Ads account, use recharge_ads() instead. Amount must be an integer between 1 and 1 000 000 000 TON. -Your wallet must hold at least the top-up amount + ~0.056 TON for gas. +Your wallet must satisfy the current minimum TON threshold and transaction cost. """ import asyncio diff --git a/pyfragment/__init__.py b/pyfragment/__init__.py index daee3af..78c492b 100644 --- a/pyfragment/__init__.py +++ b/pyfragment/__init__.py @@ -15,6 +15,7 @@ from pyfragment.types import ( CookieError, CookieResult, FragmentAPIError, + # exceptions FragmentError, FragmentPageError, GiftsResult, @@ -22,9 +23,12 @@ from pyfragment.types import ( NumbersResult, OperationError, ParseError, + # literal types + PaymentMethod, PremiumGiveawayResult, PremiumResult, StarsGiveawayResult, + # results StarsResult, TerminateSessionsResult, TransactionError, @@ -41,31 +45,35 @@ __version__: str = version("pyfragment") __all__ = [ "__version__", "FragmentClient", - "AdsRechargeResult", + # results + "StarsResult", + "StarsGiveawayResult", + "PremiumResult", + "PremiumGiveawayResult", + "WalletInfo", "AdsTopupResult", + "AdsRechargeResult", + "CookieResult", "GiftsResult", "LoginCodeResult", "NumbersResult", - "PremiumGiveawayResult", - "PremiumResult", - "StarsGiveawayResult", - "StarsResult", "TerminateSessionsResult", "UsernamesResult", - "WalletInfo", - "ClientError", - "ConfigurationError", - "CookieError", - "CookieResult", - "FragmentAPIError", + # exceptions "FragmentError", + "FragmentAPIError", "FragmentPageError", + "ConfigurationError", + "UserNotFoundError", + "WalletError", + "VerificationError", + "TransactionError", "AnonymousNumberError", + "ClientError", + "CookieError", "OperationError", "ParseError", - "TransactionError", "UnexpectedError", - "UserNotFoundError", - "VerificationError", - "WalletError", + # literal types + "PaymentMethod", ] diff --git a/pyfragment/client.py b/pyfragment/client.py index c542d4c..992d033 100644 --- a/pyfragment/client.py +++ b/pyfragment/client.py @@ -36,6 +36,7 @@ from pyfragment.types.constants import ( FRAGMENT_BASE_URL, REQUIRED_COOKIE_KEYS, SUPPORTED_WALLET_VERSIONS, + PaymentMethod, WalletVersion, ) from pyfragment.utils.http import fragment_request, get_fragment_hash, make_headers @@ -125,31 +126,45 @@ class FragmentClient: def __repr__(self) -> str: return f"FragmentClient(wallet_version='{self.wallet_version}', cookies={len(self.cookies)} keys)" - async def purchase_premium(self, username: str, months: int, show_sender: bool = True) -> PremiumResult: + async def purchase_premium( + self, + username: str, + months: int, + show_sender: bool = True, + payment_method: PaymentMethod = "ton", + ) -> PremiumResult: """Gift Telegram Premium to a user. Args: - username: Recipient's Telegram username (with or without ``@``). + 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"`` (default) or ``"usdt_ton"``. Returns: :class:`PremiumResult` with ``transaction_id``, ``username``, and ``amount``. """ - return await purchase_premium(self, username, months, show_sender) + return await purchase_premium(self, username, months, show_sender, payment_method) - async def purchase_stars(self, username: str, amount: int, show_sender: bool = True) -> StarsResult: + async def purchase_stars( + self, + username: str, + amount: int, + show_sender: bool = True, + payment_method: PaymentMethod = "ton", + ) -> StarsResult: """Send Telegram Stars to a user. Args: - username: Recipient's Telegram username (with or without ``@``). + username: Recipient identifier — ``@username``, ``username``, or ``https://t.me/username``. amount: Number of stars — integer from ``50`` to ``1 000 000``. show_sender: Show your name as the gift sender. Defaults to ``True``. + payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``. Returns: :class:`StarsResult` with ``transaction_id``, ``username``, and ``amount``. """ - return await purchase_stars(self, username, amount, show_sender) + return await purchase_stars(self, username, amount, show_sender, payment_method) async def topup_ton(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult: """Top up TON to a recipient's Telegram balance. @@ -178,11 +193,12 @@ class FragmentClient: return await recharge_ads(self, account, amount) async def get_wallet(self) -> WalletInfo: - """Return the address, state and balance of the TON wallet. + """Return the address, state, and balances of the wallet. Returns: :class:`WalletInfo` with ``address`` (``"UQ..."``), ``state`` - (``"active"``, ``"uninit"``, ``"nonexist"``, or ``"frozen"``), and ``balance`` in TON. + (``"active"``, ``"uninit"``, ``"nonexist"``, or ``"frozen"``), + ``ton_balance`` in TON, and ``usdt_balance`` in USDT. """ return await get_wallet_info(self) @@ -191,38 +207,42 @@ class FragmentClient: channel: str, winners: int, amount: int, + payment_method: PaymentMethod = "ton", ) -> StarsGiveawayResult: """Run a Telegram Stars giveaway for a channel. Args: - channel: Channel username (with or without ``@``). + channel: Channel identifier — ``@channel``, ``channel``, or ``https://t.me/channel``. winners: Number of winners — integer from ``1`` to ``5``. amount: Stars each winner receives — integer from ``500`` to ``1 000 000``. + payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``. Returns: :class:`StarsGiveawayResult` with ``transaction_id``, ``channel``, ``winners``, and ``amount``. """ - return await giveaway_stars(self, channel, winners, amount) + return await giveaway_stars(self, channel, winners, amount, payment_method) async def giveaway_premium( self, channel: str, winners: int, months: int = 3, + payment_method: PaymentMethod = "ton", ) -> PremiumGiveawayResult: """Run a Telegram Premium giveaway for a channel. Args: - channel: Channel username (with or without ``@``). + channel: Channel identifier — ``@channel``, ``channel``, or ``https://t.me/channel``. winners: Number of winners — positive integer. months: Premium duration per winner — ``3``, ``6``, or ``12``. Defaults to ``3``. + payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``. Returns: :class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``, ``winners``, and ``amount``. """ - return await giveaway_premium(self, channel, winners, months) + return await giveaway_premium(self, channel, winners, months, payment_method) async def get_login_code(self, number: str) -> LoginCodeResult: """Fetch the current pending login code for an anonymous number. diff --git a/pyfragment/methods/giveaway_premium.py b/pyfragment/methods/giveaway_premium.py index 2c6675d..8acb0ea 100644 --- a/pyfragment/methods/giveaway_premium.py +++ b/pyfragment/methods/giveaway_premium.py @@ -12,8 +12,8 @@ from pyfragment.types import ( UserNotFoundError, VerificationError, ) -from pyfragment.types.constants import DEVICE, PREMIUM_GIVEAWAY_PAGE -from pyfragment.utils import get_account_info, process_transaction +from pyfragment.types.constants import DEVICE, PREMIUM_GIVEAWAY_PAGE, SUPPORTED_PAYMENT_METHODS, PaymentMethod +from pyfragment.utils import get_account_info, parse_required_payment_amount, process_transaction if TYPE_CHECKING: from pyfragment.client import FragmentClient @@ -24,14 +24,16 @@ async def giveaway_premium( channel: str, winners: int, months: int = 3, + payment_method: PaymentMethod = "ton", ) -> PremiumGiveawayResult: """Run a Telegram Premium giveaway for a channel. Args: client: Authenticated :class:`FragmentClient` instance. - channel: Channel username (with or without ``@``). + 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"`` (default) or ``"usdt_ton"``. Returns: :class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``, @@ -47,6 +49,13 @@ async def giveaway_premium( raise ConfigurationError(ConfigurationError.INVALID_WINNERS_PREMIUM) if months not in (3, 6, 12): raise ConfigurationError(ConfigurationError.INVALID_MONTHS) + if payment_method not in SUPPORTED_PAYMENT_METHODS: + raise ConfigurationError( + ConfigurationError.INVALID_PAYMENT_METHOD.format( + method=payment_method, + supported=", ".join(sorted(SUPPORTED_PAYMENT_METHODS)), + ) + ) try: result = await client.call( @@ -60,9 +69,15 @@ async def giveaway_premium( result = await client.call( "initGiveawayPremiumRequest", - {"recipient": recipient, "quantity": str(winners), "months": str(months)}, + { + "recipient": recipient, + "quantity": str(winners), + "months": str(months), + "payment_method": payment_method, + }, page_url=PREMIUM_GIVEAWAY_PAGE, ) + required_payment_amount = parse_required_payment_amount(result) req_id = result.get("req_id") if not req_id: raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium giveaway")) @@ -81,7 +96,12 @@ async def giveaway_premium( if transaction.get("need_verify"): raise VerificationError(VerificationError.KYC_REQUIRED) - tx_hash = await process_transaction(client, transaction) + tx_hash = await process_transaction( + client, + transaction, + payment_method=payment_method, + required_payment_amount=required_payment_amount, + ) return PremiumGiveawayResult( transaction_id=tx_hash, channel=channel, diff --git a/pyfragment/methods/giveaway_stars.py b/pyfragment/methods/giveaway_stars.py index 06646fb..90b3dc7 100644 --- a/pyfragment/methods/giveaway_stars.py +++ b/pyfragment/methods/giveaway_stars.py @@ -12,8 +12,8 @@ from pyfragment.types import ( UserNotFoundError, VerificationError, ) -from pyfragment.types.constants import DEVICE, STARS_GIVEAWAY_PAGE -from pyfragment.utils import get_account_info, process_transaction +from pyfragment.types.constants import DEVICE, STARS_GIVEAWAY_PAGE, SUPPORTED_PAYMENT_METHODS, PaymentMethod +from pyfragment.utils import get_account_info, parse_required_payment_amount, process_transaction if TYPE_CHECKING: from pyfragment.client import FragmentClient @@ -24,14 +24,16 @@ async def giveaway_stars( channel: str, winners: int, amount: int, + payment_method: PaymentMethod = "ton", ) -> StarsGiveawayResult: """Run a Telegram Stars giveaway for a channel. Args: client: Authenticated :class:`FragmentClient` instance. - channel: Channel username (with or without ``@``). + channel: Channel identifier — ``@channel``, ``channel``, or ``https://t.me/channel``. winners: Number of winners — integer from ``1`` to ``5``. amount: Stars each winner receives — integer from ``500`` to ``1 000 000``. + payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``. Returns: :class:`StarsGiveawayResult` with ``transaction_id``, ``channel``, @@ -47,6 +49,13 @@ async def giveaway_stars( raise ConfigurationError(ConfigurationError.INVALID_WINNERS_STARS) if not isinstance(amount, int) or not (500 <= amount <= 1_000_000): raise ConfigurationError(ConfigurationError.INVALID_STARS_PER_WINNER) + if payment_method not in SUPPORTED_PAYMENT_METHODS: + raise ConfigurationError( + ConfigurationError.INVALID_PAYMENT_METHOD.format( + method=payment_method, + supported=", ".join(sorted(SUPPORTED_PAYMENT_METHODS)), + ) + ) try: result = await client.call("searchStarsGiveawayRecipient", {"query": channel}, page_url=STARS_GIVEAWAY_PAGE) @@ -56,9 +65,15 @@ async def giveaway_stars( result = await client.call( "initGiveawayStarsRequest", - {"recipient": recipient, "quantity": str(winners), "stars": str(amount)}, + { + "recipient": recipient, + "quantity": str(winners), + "stars": str(amount), + "payment_method": payment_method, + }, page_url=STARS_GIVEAWAY_PAGE, ) + required_payment_amount = parse_required_payment_amount(result) req_id = result.get("req_id") if not req_id: raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars giveaway")) @@ -77,7 +92,12 @@ async def giveaway_stars( if transaction.get("need_verify"): raise VerificationError(VerificationError.KYC_REQUIRED) - tx_hash = await process_transaction(client, transaction) + tx_hash = await process_transaction( + client, + transaction, + payment_method=payment_method, + required_payment_amount=required_payment_amount, + ) return StarsGiveawayResult( transaction_id=tx_hash, channel=channel, diff --git a/pyfragment/methods/purchase_premium.py b/pyfragment/methods/purchase_premium.py index 771cf8e..5924fe1 100644 --- a/pyfragment/methods/purchase_premium.py +++ b/pyfragment/methods/purchase_premium.py @@ -13,21 +13,28 @@ from pyfragment.types import ( UserNotFoundError, VerificationError, ) -from pyfragment.types.constants import DEVICE, PREMIUM_PAGE -from pyfragment.utils import get_account_info, process_transaction +from pyfragment.types.constants import DEVICE, PREMIUM_PAGE, SUPPORTED_PAYMENT_METHODS, PaymentMethod +from pyfragment.utils import get_account_info, parse_required_payment_amount, process_transaction if TYPE_CHECKING: from pyfragment.client import FragmentClient -async def purchase_premium(client: FragmentClient, username: str, months: int, show_sender: bool = True) -> PremiumResult: +async def purchase_premium( + client: FragmentClient, + username: str, + months: int, + show_sender: bool = True, + payment_method: PaymentMethod = "ton", +) -> PremiumResult: """Gift Telegram Premium to a user. Args: client: Authenticated :class:`FragmentClient` instance. - username: Recipient's Telegram username (with or without ``@``). + username: Recipient identifier — ``@username``, ``username``, or ``https://t.me/username``. months: Premium duration — ``3``, ``6``, or ``12``. show_sender: Show your name as the gift sender. Defaults to ``True``. + payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``. Returns: :class:`PremiumResult` with ``transaction_id``, ``username``, and ``amount``. @@ -40,6 +47,13 @@ async def purchase_premium(client: FragmentClient, username: str, months: int, s """ if months not in (3, 6, 12): raise ConfigurationError(ConfigurationError.INVALID_MONTHS) + if payment_method not in SUPPORTED_PAYMENT_METHODS: + raise ConfigurationError( + ConfigurationError.INVALID_PAYMENT_METHOD.format( + method=payment_method, + supported=", ".join(sorted(SUPPORTED_PAYMENT_METHODS)), + ) + ) try: result = await client.call("searchPremiumGiftRecipient", {"query": username, "months": months}, page_url=PREMIUM_PAGE) @@ -52,7 +66,12 @@ async def purchase_premium(client: FragmentClient, username: str, months: int, s {"mode": "new", "lv": "false", "dh": str(int(time.time()))}, page_url=PREMIUM_PAGE, ) - result = await client.call("initGiftPremiumRequest", {"recipient": recipient, "months": months}, page_url=PREMIUM_PAGE) + result = await client.call( + "initGiftPremiumRequest", + {"recipient": recipient, "months": months, "payment_method": payment_method}, + page_url=PREMIUM_PAGE, + ) + required_payment_amount = parse_required_payment_amount(result) req_id = result.get("req_id") if not req_id: raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium purchase")) @@ -72,7 +91,12 @@ async def purchase_premium(client: FragmentClient, username: str, months: int, s if transaction.get("need_verify"): raise VerificationError(VerificationError.KYC_REQUIRED) - tx_hash = await process_transaction(client, transaction) + tx_hash = await process_transaction( + client, + transaction, + payment_method=payment_method, + required_payment_amount=required_payment_amount, + ) return PremiumResult(transaction_id=tx_hash, username=username, amount=months) except FragmentError: diff --git a/pyfragment/methods/purchase_stars.py b/pyfragment/methods/purchase_stars.py index 923682a..44c0e1e 100644 --- a/pyfragment/methods/purchase_stars.py +++ b/pyfragment/methods/purchase_stars.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import time from typing import TYPE_CHECKING from pyfragment.types import ( @@ -12,21 +13,24 @@ from pyfragment.types import ( UserNotFoundError, VerificationError, ) -from pyfragment.types.constants import DEVICE, STARS_PAGE -from pyfragment.utils import get_account_info, process_transaction +from pyfragment.types.constants import DEVICE, STARS_PAGE, SUPPORTED_PAYMENT_METHODS, PaymentMethod +from pyfragment.utils import get_account_info, parse_required_payment_amount, process_transaction if TYPE_CHECKING: from pyfragment.client import FragmentClient -async def purchase_stars(client: FragmentClient, username: str, amount: int, show_sender: bool = True) -> StarsResult: +async def purchase_stars( + client: FragmentClient, username: str, amount: int, show_sender: bool = True, payment_method: PaymentMethod = "ton" +) -> StarsResult: """Send Telegram Stars to a user. Args: client: Authenticated :class:`FragmentClient` instance. - username: Recipient's Telegram username (with or without ``@``). + username: Recipient identifier — ``@username``, ``username``, or ``https://t.me/username``. amount: Number of Stars to send — integer from ``50`` to ``1 000 000``. show_sender: Show your name as the gift sender. Defaults to ``True``. + payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``. Returns: :class:`StarsResult` with ``transaction_id``, ``username``, and ``amount``. @@ -39,6 +43,13 @@ async def purchase_stars(client: FragmentClient, username: str, amount: int, sho """ if not isinstance(amount, int) or not (50 <= amount <= 1_000_000): raise ConfigurationError(ConfigurationError.INVALID_STARS_AMOUNT) + if payment_method not in SUPPORTED_PAYMENT_METHODS: + raise ConfigurationError( + ConfigurationError.INVALID_PAYMENT_METHOD.format( + method=payment_method, + supported=", ".join(sorted(SUPPORTED_PAYMENT_METHODS)), + ) + ) try: result = await client.call("searchStarsRecipient", {"query": username, "quantity": ""}, page_url=STARS_PAGE) @@ -46,7 +57,17 @@ async def purchase_stars(client: FragmentClient, username: str, amount: int, sho if not recipient: raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username)) - result = await client.call("initBuyStarsRequest", {"recipient": recipient, "quantity": amount}, page_url=STARS_PAGE) + await client.call( + "updateStarsBuyState", + {"mode": "new", "lv": "false", "dh": str(int(time.time()))}, + page_url=STARS_PAGE, + ) + result = await client.call( + "initBuyStarsRequest", + {"recipient": recipient, "quantity": amount, "payment_method": payment_method}, + page_url=STARS_PAGE, + ) + required_payment_amount = parse_required_payment_amount(result) req_id = result.get("req_id") if not req_id: raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars purchase")) @@ -66,7 +87,12 @@ async def purchase_stars(client: FragmentClient, username: str, amount: int, sho if transaction.get("need_verify"): raise VerificationError(VerificationError.KYC_REQUIRED) - tx_hash = await process_transaction(client, transaction) + tx_hash = await process_transaction( + client, + transaction, + payment_method=payment_method, + required_payment_amount=required_payment_amount, + ) return StarsResult(transaction_id=tx_hash, username=username, amount=amount) except FragmentError: diff --git a/pyfragment/types/__init__.py b/pyfragment/types/__init__.py index 52526a6..e8c7ffe 100644 --- a/pyfragment/types/__init__.py +++ b/pyfragment/types/__init__.py @@ -1,3 +1,4 @@ +from pyfragment.types.constants import PaymentMethod from pyfragment.types.exceptions import ( AnonymousNumberError, ClientError, @@ -61,4 +62,6 @@ __all__ = [ "TerminateSessionsResult", "UsernamesResult", "WalletInfo", + # literal types + "PaymentMethod", ] diff --git a/pyfragment/types/constants.py b/pyfragment/types/constants.py index d552234..af7b29d 100644 --- a/pyfragment/types/constants.py +++ b/pyfragment/types/constants.py @@ -5,6 +5,10 @@ from typing import Any, Literal, get_args from tonutils.contracts.wallet import WalletV4R2, WalletV5R1 +# Payment methods +PaymentMethod = Literal["ton", "usdt_ton"] +SUPPORTED_PAYMENT_METHODS: frozenset[str] = frozenset(get_args(PaymentMethod)) + # Single source of truth for supported wallet versions WalletVersion = Literal["V4R2", "V5R1"] SUPPORTED_WALLET_VERSIONS: frozenset[str] = frozenset(get_args(WalletVersion)) @@ -12,8 +16,12 @@ SUPPORTED_WALLET_VERSIONS: frozenset[str] = frozenset(get_args(WalletVersion)) # Wallet class map — used to resolve the correct contract from WALLET_VERSION WALLET_CLASSES: dict[str, Any] = {"V4R2": WalletV4R2, "V5R1": WalletV5R1} -# Minimum wallet balance required to cover TON network gas fees. -MIN_TON_BALANCE: float = 0.056 +# Minimum TON balance threshold required for payment flows. +MIN_TON_BALANCE: float = 0.33 + +# USDT (TON) jetton metadata used for payment-method balance checks. +USDT_TON_MASTER_ADDRESS: str = "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs" +MIN_USDT_BALANCE: float = 0.75 # Default HTTP request timeout in seconds. DEFAULT_TIMEOUT: float = 30.0 @@ -74,12 +82,15 @@ BASE_HEADERS: dict[str, str] = { "content-type": "application/x-www-form-urlencoded; charset=UTF-8", "origin": FRAGMENT_BASE_URL, "priority": "u=1, i", + "sec-ch-ua": '"Google Chrome";v="147", "Not.A/Brand";v="8", "Chromium";v="147"', + "sec-ch-ua-mobile": "?1", + "sec-ch-ua-platform": '"Android"', "sec-fetch-dest": "empty", "sec-fetch-mode": "cors", "sec-fetch-site": "same-origin", "user-agent": ( - "Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) " - "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1" + "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36" ), "x-requested-with": "XMLHttpRequest", } diff --git a/pyfragment/types/exceptions.py b/pyfragment/types/exceptions.py index 5f34a29..6f8f621 100644 --- a/pyfragment/types/exceptions.py +++ b/pyfragment/types/exceptions.py @@ -28,6 +28,7 @@ class ConfigurationError(ClientError): INVALID_WINNERS_STARS = "Invalid winners count: must be an integer between 1 and 5." INVALID_WINNERS_PREMIUM = "Invalid winners count: must be an integer between 1 and 24 000." INVALID_STARS_PER_WINNER = "Invalid Stars per winner: must be an integer between 500 and 1 000 000." + INVALID_PAYMENT_METHOD = "Invalid payment method '{method}'. Supported values: {supported}." class CookieError(ClientError): @@ -130,11 +131,10 @@ class OperationError(FragmentError): class WalletError(OperationError): """Raised for TON wallet issues (connection, balance, account info).""" - LOW_BALANCE = ( - "Insufficient TON balance: {balance:.4f} TON available, {required:.4f} TON required " - "(transaction amount + {gas:.3f} TON gas reserve)." - ) - BALANCE_CHECK_FAILED = "Failed to fetch wallet balance: {exc}" + LOW_TON_BALANCE = "Insufficient TON balance: {balance:.4f} TON available, {required:.4f} TON required." + LOW_USDT_BALANCE = "Insufficient USDT balance: {balance:.4f} USDT available, {required:.4f} USDT required." + TON_BALANCE_CHECK_FAILED = "Failed to fetch TON balance: {exc}" + USDT_BALANCE_CHECK_FAILED = "Failed to fetch USDT balance: {exc}" ACCOUNT_INFO_FAILED = "Failed to retrieve wallet account info from TON network: {exc}" WALLET_INFO_FAILED = "Failed to retrieve wallet info from TON network: {exc}" diff --git a/pyfragment/types/results.py b/pyfragment/types/results.py index 1e4cecc..7ae9017 100644 --- a/pyfragment/types/results.py +++ b/pyfragment/types/results.py @@ -27,10 +27,14 @@ class WalletInfo: address: str state: str - balance: float + ton_balance: float + usdt_balance: float def __repr__(self) -> str: - return f"WalletInfo(address='{self.address}', state='{self.state}', balance={self.balance} TON)" + return ( + f"WalletInfo(address='{self.address}', state='{self.state}', " + f"ton_balance={self.ton_balance} TON, usdt_balance={self.usdt_balance} USDT)" + ) @dataclass diff --git a/pyfragment/utils/__init__.py b/pyfragment/utils/__init__.py index 45e96d7..cedd637 100644 --- a/pyfragment/utils/__init__.py +++ b/pyfragment/utils/__init__.py @@ -1,6 +1,6 @@ from pyfragment.utils.cookies import CookieResult, get_cookies_from_browser from pyfragment.utils.decoder import clean_decode -from pyfragment.utils.html import parse_auction_rows, parse_gift_items, parse_login_code +from pyfragment.utils.html import parse_auction_rows, parse_gift_items, parse_login_code, parse_required_payment_amount from pyfragment.utils.http import ( execute_transaction_request, fragment_request, @@ -17,6 +17,7 @@ __all__ = [ "parse_auction_rows", "parse_gift_items", "parse_login_code", + "parse_required_payment_amount", "execute_transaction_request", "fragment_request", "get_account_info", diff --git a/pyfragment/utils/decoder.py b/pyfragment/utils/decoder.py index d38a692..4285466 100644 --- a/pyfragment/utils/decoder.py +++ b/pyfragment/utils/decoder.py @@ -7,7 +7,7 @@ from ton_core import Cell from pyfragment.types import ParseError -def clean_decode(payload: str) -> str: +def clean_decode(payload: str) -> str | Cell: """Decode a base64-encoded BOC payload to a plain-text comment string. Fragment transaction payloads are BOC-serialised TVM cells. This function @@ -18,7 +18,8 @@ def clean_decode(payload: str) -> str: payload: Base64url-encoded BOC string (padding is added automatically). Returns: - Decoded comment string, or ``""`` for an empty payload. + Decoded comment string, ``""`` for an empty payload, or raw ``Cell`` + when payload is a non-UTF8 binary body. Raises: ParseError: If the payload cannot be decoded or parsed. @@ -28,10 +29,15 @@ def clean_decode(payload: str) -> str: return "" s += "=" * (-len(s) % 4) try: - boc = base64.b64decode(s) + # Fragment may return URL-safe base64 ("-"/"_") in transaction payloads. + boc = base64.b64decode(s, altchars=b"-_", validate=True) cell = Cell.one_from_boc(boc) sl = cell.begin_parse() - sl.load_uint(32) # op code — always 0 for text comment - return sl.load_snake_string().strip() + sl.load_uint(32) # op code + try: + return sl.load_snake_string().strip() + except UnicodeDecodeError: + # Some Fragment payloads are binary TVM cells rather than text comments. + return cell except Exception as exc: raise ParseError(ParseError.UNPARSEABLE.format(context="payload decode", exc=exc)) from exc diff --git a/pyfragment/utils/html.py b/pyfragment/utils/html.py index 28defd7..d0b98b6 100644 --- a/pyfragment/utils/html.py +++ b/pyfragment/utils/html.py @@ -161,3 +161,12 @@ def parse_gift_items(html: str) -> tuple[list[dict[str, Any]], int | None]: next_offset = int(next_offset_m.group(1)) if next_offset_m else None return items, next_offset + + +def parse_required_payment_amount(init_response: dict[str, Any]) -> float | None: + """Extract required payment amount from init*Request response.""" + raw_amount = init_response.get("amount") + try: + return float(str(raw_amount)) + except (TypeError, ValueError): + return None diff --git a/pyfragment/utils/wallet.py b/pyfragment/utils/wallet.py index d5c4ee6..2212690 100644 --- a/pyfragment/utils/wallet.py +++ b/pyfragment/utils/wallet.py @@ -8,17 +8,91 @@ from typing import TYPE_CHECKING, Any from ton_core import NetworkGlobalID from tonutils.clients import TonapiClient +from tonutils.contracts.jetton import get_wallet_address_get_method, get_wallet_data_get_method from tonutils.exceptions import ProviderResponseError from pyfragment.types import TransactionError, WalletError, WalletInfo -from pyfragment.types.constants import MIN_TON_BALANCE, WALLET_CLASSES +from pyfragment.types.constants import ( + MIN_TON_BALANCE, + MIN_USDT_BALANCE, + USDT_TON_MASTER_ADDRESS, + WALLET_CLASSES, + PaymentMethod, +) from pyfragment.utils.decoder import clean_decode if TYPE_CHECKING: from pyfragment.client import FragmentClient -async def process_transaction(client: FragmentClient, transaction_data: dict[str, Any]) -> str: +async def _get_usdt_balance(ton: Any, wallet_address: str) -> float: + """Return wallet USDT balance via tonutils jetton get-methods.""" + try: + jetton_wallet_address = await get_wallet_address_get_method( + client=ton, + address=USDT_TON_MASTER_ADDRESS, + owner_address=wallet_address, + ) + wallet_data = await get_wallet_data_get_method(client=ton, address=jetton_wallet_address) + raw_balance = int(wallet_data[0]) if wallet_data else 0 + return float(raw_balance) / 1_000_000.0 + except ProviderResponseError as exc: + # No jetton wallet deployed yet -> effectively zero USDT balance. + if exc.code == 404: + return 0.0 + raise WalletError(WalletError.USDT_BALANCE_CHECK_FAILED.format(exc=exc)) from exc + except Exception as exc: + raise WalletError(WalletError.USDT_BALANCE_CHECK_FAILED.format(exc=exc)) from exc + + +async def _check_ton_payment_balance( + balance_ton: float, + amount_ton: float, + required_payment_amount: float | None, +) -> None: + """Validate balance requirements for TON payment method.""" + tx_price_ton = amount_ton + if required_payment_amount is not None and required_payment_amount > 0: + tx_price_ton = max(tx_price_ton, required_payment_amount) + + required_ton = max(tx_price_ton, MIN_TON_BALANCE) + if balance_ton < required_ton: + raise WalletError( + WalletError.LOW_TON_BALANCE.format( + balance=balance_ton, + required=required_ton, + ) + ) + + +async def _check_usdt_payment_balance( + balance_ton: float, + required_payment_amount: float | None, + ton: Any, + wallet_address: str, +) -> None: + """Validate balance requirements for USDT payment method.""" + # USDT payment still needs TON for network fees. + if balance_ton < MIN_TON_BALANCE: + raise WalletError( + WalletError.LOW_TON_BALANCE.format( + balance=balance_ton, + required=MIN_TON_BALANCE, + ) + ) + + usdt_balance = await _get_usdt_balance(ton, wallet_address) + required_usdt = required_payment_amount if required_payment_amount is not None else MIN_USDT_BALANCE + if usdt_balance < required_usdt: + raise WalletError(WalletError.LOW_USDT_BALANCE.format(balance=usdt_balance, required=required_usdt)) + + +async def process_transaction( + client: FragmentClient, + transaction_data: dict[str, Any], + payment_method: PaymentMethod = "ton", + required_payment_amount: float | None = None, +) -> str: """Sign and broadcast a Fragment transaction to the TON network. Validates the payload structure, checks the wallet balance, decodes the @@ -27,6 +101,8 @@ async def process_transaction(client: FragmentClient, transaction_data: dict[str Args: client: Authenticated :class:`FragmentClient` instance. transaction_data: Raw transaction dict from ``execute_transaction_request``. + payment_method: Payment currency — ``"ton"`` or ``"usdt_ton"``. + required_payment_amount: Optional price from init*Request response. Returns: Normalised transaction hash string. @@ -45,20 +121,32 @@ async def process_transaction(client: FragmentClient, transaction_data: dict[str wallet_cls = WALLET_CLASSES[client.wallet_version] wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed) - # Check balance covers transaction amount + gas reserve + # Check balance covers selected payment flow requirements. try: await wallet.refresh() balance_ton = wallet.balance / 1_000_000_000 - required = amount_ton + MIN_TON_BALANCE - if balance_ton < required: - raise WalletError(WalletError.LOW_BALANCE.format(balance=balance_ton, required=required, gas=MIN_TON_BALANCE)) + wallet_address = wallet.address.to_str(False, False) + if payment_method == "ton": + await _check_ton_payment_balance( + balance_ton, + amount_ton, + required_payment_amount, + ) + else: + await _check_usdt_payment_balance( + balance_ton, + required_payment_amount, + ton, + wallet_address, + ) except WalletError: raise except Exception as exc: - raise WalletError(WalletError.BALANCE_CHECK_FAILED.format(exc=exc)) from exc + raise WalletError(WalletError.TON_BALANCE_CHECK_FAILED.format(exc=exc)) from exc try: - payload = clean_decode(message["payload"]) + raw_payload = str(message.get("payload", "")) + payload = clean_decode(raw_payload) for attempt in range(3): try: @@ -130,7 +218,8 @@ async def get_wallet_info(client: FragmentClient) -> WalletInfo: client: Authenticated :class:`FragmentClient` instance. Returns: - :class:`WalletInfo` with ``address``, ``state``, and ``balance`` in TON. + :class:`WalletInfo` with ``address``, ``state``, ``balance`` in TON, + and ``usdt_balance`` in USDT. Raises: WalletError: If the wallet state cannot be fetched. @@ -140,10 +229,13 @@ async def get_wallet_info(client: FragmentClient) -> WalletInfo: wallet_cls = WALLET_CLASSES[client.wallet_version] wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed) await wallet.refresh() + wallet_address = wallet.address.to_str(False, False) + usdt_balance = await _get_usdt_balance(ton, wallet_address) return WalletInfo( address=wallet.address.to_str(is_user_friendly=True, is_bounceable=False), state=wallet.state.value, - balance=round(wallet.balance / 1_000_000_000, 4), + ton_balance=round(wallet.balance / 1_000_000_000, 4), + usdt_balance=round(usdt_balance, 4), ) except Exception as exc: raise WalletError(WalletError.WALLET_INFO_FAILED.format(exc=exc)) from exc diff --git a/pyproject.toml b/pyproject.toml index d9fe781..15f6361 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ build-backend = "hatchling.build" [project] name = "pyfragment" -version = "2026.2.1" -description = "Async Python client for the Fragment API — a unified toolkit to manage Telegram assets: purchase Stars and Premium, top up TON and Ads balances, run giveaways, manage anonymous numbers, and explore the marketplace for usernames, numbers, and gifts." +version = "2026.2.2" +description = "Async Python client for the Fragment API. Buy Stars and Premium, top up TON and Ads balances, run giveaways, manage anonymous numbers, and search Fragment listings." readme = "README.md" license = { text = "MIT" } requires-python = ">=3.10" @@ -14,19 +14,23 @@ keywords = [ "fragment", "fragment-api", "telegram", + "telegram-api", "telegram-stars", "telegram-premium", + "telegram-giveaway", + "telegram-ads", "ton", + "ton-blockchain", "tonkeeper", "tonapi", - "crypto", - "blockchain", + "anonymous-numbers", + "username-auctions", + "gift-marketplace", + "crypto-payments", + "nft-marketplace", "web3", - "giveaway", - "anonymous-number", - "username", - "nft", - "async", + "python-client", + "typed", "asyncio", ] classifiers = [ diff --git a/tests/001_test_decode.py b/tests/001_test_decode.py index 8a60ac3..0541c4b 100644 --- a/tests/001_test_decode.py +++ b/tests/001_test_decode.py @@ -1,37 +1,62 @@ """Tests for clean_decode() — TON BOC payload decoding.""" +import base64 import re +from unittest.mock import patch import pytest +from ton_core import Cell from pyfragment.types import ParseError from pyfragment.utils.decoder import clean_decode -PAYLOADS = [ +PAYLOAD_CASES = [ pytest.param( "te6ccgEBAgEALwABTgAAAAAxMDAwMDAwIFRlbGVncmFtIFN0YXJzIAoKUmVmI1RQb01wegEABkM3ZQ", + True, id="stars", ), pytest.param( "te6ccgEBAgEANAABTgAAAABUZWxlZ3JhbSBQcmVtaXVtIGZvciAxIHllYXIgCgpSZWYjcgEAEE9OQnM2cmNt", + True, id="premium", ), pytest.param( "te6ccgEBAgEAMAABTgAAAABUZWxlZ3JhbSBhY2NvdW50IHRvcCB1cCAKClJlZiNrMXpDRQEACFkxd3g", + True, id="topup", ), + pytest.param( + "te6ccgEBAgEAfgABqA-KfqVP885dhccidjC3GwgBCkiH8LM_zUu0afyGCTWJwX1mDjdlf2rMa9UoQlD4UHUAF1jLlcMomlo5RJTwl8jnDDdfdhc7EgQQWPqFQ9IjyLPCAwEASgAAAAA1MCBUZWxlZ3JhbSBTdGFycyAKClJlZiNtOUpoWndBcFE", + False, + id="real_stars_50", + ), + pytest.param( + "te6ccgEBAgEANgABTgAAAABUZWxlZ3JhbSBQcmVtaXVtIGZvciAzIG1vbnRocyAKClJlZgEAFCMzcFdKdGJkYnU", + False, + id="real_premium_3m", + ), + pytest.param( + "te6ccgEBAwEAhgABqg-KfqWibdDaYaJCPUWWgvAIAQpIh_CzP81LtGn8hgk1icF9Zg43ZX9qzGvVKEJQ-FB1ABdYy5XDKJpaOUSU8JfI5ww3X3YXOxIEEFj6hUPSI8izwgMBAU4AAAAAMTAwMDAwIFRlbGVncmFtIFN0YXJzIAoKUmVmIzBoZ0RmNEYCAAQ5VA", + False, + id="real_stars_100k", + ), ] # Decode valid payload tests -@pytest.mark.parametrize("payload", PAYLOADS) -def test_decode_payload(payload: str) -> None: +@pytest.mark.parametrize(("payload", "strict_ref"), PAYLOAD_CASES) +def test_decode_payload(payload: str, strict_ref: bool) -> None: result = clean_decode(payload) - assert "Telegram" in result - assert re.search(r"Ref#[A-Za-z0-9]+", result), f"no Ref# in {result!r}" - assert all(ord(c) < 128 for c in result), f"non-ASCII chars in {result!r}" + if isinstance(result, str): + assert "Telegram" in result + if strict_ref: + assert re.search(r"Ref#[A-Za-z0-9]+", result), f"no Ref# in {result!r}" + assert all(ord(c) < 128 for c in result), f"non-ASCII chars in {result!r}" + else: + assert isinstance(result, Cell) # Edge case tests @@ -44,3 +69,64 @@ def test_empty_payload_returns_empty_string() -> None: def test_invalid_payload_raises_parse_error() -> None: with pytest.raises(ParseError): clean_decode("!!!not-valid-base64!!!") + + +def test_decode_payload_accepts_base64url_alphabet() -> None: + class _FakeSlice: + def load_uint(self, _: int) -> int: + return 0 + + def load_snake_string(self) -> str: + return "Telegram Stars Ref#abc" + + class _FakeCell: + def begin_parse(self) -> _FakeSlice: + return _FakeSlice() + + raw = b"\xfb\xef\xff\x00" + payload = base64.urlsafe_b64encode(raw).decode().rstrip("=") + + with patch("pyfragment.utils.decoder.Cell.one_from_boc", return_value=_FakeCell()) as mocked: + result = clean_decode(payload) + + mocked.assert_called_once_with(raw) + assert result == "Telegram Stars Ref#abc" + + +def test_clean_decode_returns_text_comment_when_utf8() -> None: + class _FakeSlice: + def load_uint(self, _: int) -> int: + return 0 + + def load_snake_string(self) -> str: + return "Telegram Premium Ref#abc" + + class _FakeCell: + def begin_parse(self) -> _FakeSlice: + return _FakeSlice() + + payload = base64.urlsafe_b64encode(b"\x00\x01").decode().rstrip("=") + with patch("pyfragment.utils.decoder.Cell.one_from_boc", return_value=_FakeCell()): + parsed = clean_decode(payload) + + assert parsed == "Telegram Premium Ref#abc" + + +def test_clean_decode_returns_cell_for_binary_payload() -> None: + class _FakeSlice: + def load_uint(self, _: int) -> int: + return 0 + + def load_snake_string(self) -> str: + raise UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte") + + class _FakeCell: + def begin_parse(self) -> _FakeSlice: + return _FakeSlice() + + payload = base64.urlsafe_b64encode(b"\x00\x01").decode().rstrip("=") + fake_cell: object = _FakeCell() + with patch("pyfragment.utils.decoder.Cell.one_from_boc", return_value=fake_cell): + parsed = clean_decode(payload) + + assert parsed is fake_cell diff --git a/tests/003_test_balance.py b/tests/003_test_balance.py index 6fa91b5..d3e0ea8 100644 --- a/tests/003_test_balance.py +++ b/tests/003_test_balance.py @@ -62,7 +62,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 TON, needs 0.556 TON + wallet = _make_wallet(balance_nanotons=1_000_000_000) # 1 TON, above threshold with _patch_wallet(wallet), patch("pyfragment.utils.wallet.clean_decode", return_value="50 Telegram Stars"): result = await process_transaction(_make_client(), TRANSACTION_DATA) assert result == "abc123" @@ -71,7 +71,7 @@ async def test_sufficient_balance_broadcasts() -> None: @pytest.mark.asyncio async def test_insufficient_balance_raises() -> None: - wallet = _make_wallet(balance_nanotons=100_000_000) # 0.1 TON, needs 0.556 TON + wallet = _make_wallet(balance_nanotons=100_000_000) # 0.1 TON, below threshold with _patch_wallet(wallet): with pytest.raises(WalletError, match="required"): await process_transaction(_make_client(), TRANSACTION_DATA) @@ -80,7 +80,7 @@ async def test_insufficient_balance_raises() -> None: @pytest.mark.asyncio async def test_exact_minimum_balance_broadcasts() -> None: - wallet = _make_wallet(balance_nanotons=556_000_000) # exactly 0.5 + 0.056 TON + wallet = _make_wallet(balance_nanotons=500_000_000) # exactly transaction amount threshold with _patch_wallet(wallet), patch("pyfragment.utils.wallet.clean_decode", return_value="50 Telegram Stars"): result = await process_transaction(_make_client(), TRANSACTION_DATA) assert result == "abc123" @@ -88,7 +88,7 @@ async def test_exact_minimum_balance_broadcasts() -> None: @pytest.mark.asyncio async def test_one_nanoton_below_minimum_raises() -> None: - wallet = _make_wallet(balance_nanotons=555_999_999) # 1 nanoton below threshold + wallet = _make_wallet(balance_nanotons=499_999_999) # 1 nanoton below transaction amount threshold with _patch_wallet(wallet): with pytest.raises(WalletError, match="required"): await process_transaction(_make_client(), TRANSACTION_DATA) @@ -138,3 +138,41 @@ async def test_duplicate_seqno_raises_after_retries() -> None: with pytest.raises(TransactionError, match="seqno"): await process_transaction(_make_client(), TRANSACTION_DATA) assert wallet.transfer.call_count == 3 + + +@pytest.mark.asyncio +async def test_usdt_payment_requires_min_ton_gas_reserve() -> None: + wallet = _make_wallet(balance_nanotons=10_000_000) # 0.01 TON below MIN_TON_BALANCE + with _patch_wallet(wallet), patch("pyfragment.utils.wallet._get_usdt_balance", AsyncMock(return_value=100.0)): + with pytest.raises(WalletError, match="Insufficient TON balance"): + await process_transaction(_make_client(), TRANSACTION_DATA, payment_method="usdt_ton") + + +@pytest.mark.asyncio +async def test_usdt_payment_checks_usdt_balance() -> None: + wallet = _make_wallet(balance_nanotons=1_000_000_000) + transaction = { + "transaction": { + "messages": [ + { + "address": "0:852443f8599fe6a5da34fe43049ac4e0beb3071bb2bfb56635ea9421287c283a", + "amount": "50000000", + "payload": "", + } + ] + }, + "required_usdt": 12.5, + } + + with ( + _patch_wallet(wallet), + patch("pyfragment.utils.wallet.clean_decode", return_value=""), + patch("pyfragment.utils.wallet._get_usdt_balance", AsyncMock(return_value=5.0)), + ): + with pytest.raises(WalletError, match="Insufficient USDT balance"): + await process_transaction( + _make_client(), + transaction, + payment_method="usdt_ton", + required_payment_amount=12.5, + ) diff --git a/tests/004_test_stars.py b/tests/004_test_stars.py index e550c3c..976ad4c 100644 --- a/tests/004_test_stars.py +++ b/tests/004_test_stars.py @@ -32,23 +32,27 @@ async def test_purchase_stars_float_amount(client: FragmentClient) -> None: await client.purchase_stars("@user", amount=100.5) # type: ignore[arg-type] +@pytest.mark.asyncio +async def test_purchase_stars_invalid_payment_method(client: FragmentClient) -> None: + with pytest.raises(ConfigurationError, match="Invalid payment method"): + await client.purchase_stars("@user", amount=500, payment_method="btc") # type: ignore[arg-type] + + # Stars purchase mocked tests @pytest.mark.asyncio async def test_purchase_stars_success(client: FragmentClient) -> None: + call_mock = AsyncMock( + side_effect=[ + {"found": {"recipient": FAKE_RECIPIENT}}, + {}, # updateStarsBuyState + {"req_id": FAKE_REQ_ID}, + FAKE_TRANSACTION, + ] + ) with ( - patch.object( - client, - "call", - AsyncMock( - side_effect=[ - {"found": {"recipient": FAKE_RECIPIENT}}, - {"req_id": FAKE_REQ_ID}, - FAKE_TRANSACTION, - ] - ), - ), + patch.object(client, "call", call_mock), patch.object(_purchase_stars_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), patch.object(_purchase_stars_mod, "process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), ): @@ -60,6 +64,44 @@ async def test_purchase_stars_success(client: FragmentClient) -> None: assert result.amount == 500 +@pytest.mark.asyncio +async def test_purchase_stars_passes_payment_method(client: FragmentClient) -> None: + call_mock = AsyncMock( + side_effect=[ + {"found": {"recipient": FAKE_RECIPIENT}}, + {}, # updateStarsBuyState + {"req_id": FAKE_REQ_ID}, + FAKE_TRANSACTION, + ] + ) + proc_mock = AsyncMock(return_value=FAKE_TX_HASH) + with ( + patch.object(client, "call", call_mock), + patch.object(_purchase_stars_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch.object(_purchase_stars_mod, "process_transaction", proc_mock), + ): + await client.purchase_stars("@user", amount=500, payment_method="usdt_ton") + + init_call = call_mock.await_args_list[2] + assert init_call.args[0] == "initBuyStarsRequest" + assert init_call.args[1]["payment_method"] == "usdt_ton" + assert proc_mock.await_args is not None + assert proc_mock.await_args.kwargs["payment_method"] == "usdt_ton" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("query", ["@user", "monk", "https://t.me/monk"]) +async def test_purchase_stars_accepts_query_formats(client: FragmentClient, query: str) -> None: + call_mock = AsyncMock(return_value={"found": {}}) + with patch.object(client, "call", call_mock): + with pytest.raises(UserNotFoundError): + await client.purchase_stars(query, amount=500) + + search_call = call_mock.await_args_list[0] + assert search_call.args[0] == "searchStarsRecipient" + assert search_call.args[1]["query"] == query + + @pytest.mark.asyncio async def test_purchase_stars_user_not_found(client: FragmentClient) -> None: with patch.object(client, "call", AsyncMock(return_value={"found": {}})): @@ -106,6 +148,12 @@ async def test_giveaway_stars_float_amount(client: FragmentClient) -> None: await client.giveaway_stars("@channel", winners=1, amount=500.5) # type: ignore[arg-type] +@pytest.mark.asyncio +async def test_giveaway_stars_invalid_payment_method(client: FragmentClient) -> None: + with pytest.raises(ConfigurationError, match="Invalid payment method"): + await client.giveaway_stars("@channel", winners=1, amount=500, payment_method="btc") # type: ignore[arg-type] + + # Stars giveaway mocked tests @@ -135,6 +183,43 @@ async def test_giveaway_stars_success(client: FragmentClient) -> None: assert result.amount == 1000 +@pytest.mark.asyncio +async def test_giveaway_stars_passes_payment_method(client: FragmentClient) -> None: + call_mock = AsyncMock( + side_effect=[ + {"found": {"recipient": FAKE_RECIPIENT}}, + {"req_id": FAKE_REQ_ID}, + FAKE_TRANSACTION, + ] + ) + proc_mock = AsyncMock(return_value=FAKE_TX_HASH) + with ( + patch.object(client, "call", call_mock), + patch.object(_giveaway_stars_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch.object(_giveaway_stars_mod, "process_transaction", proc_mock), + ): + await client.giveaway_stars("@channel", winners=3, amount=1000, payment_method="usdt_ton") + + init_call = call_mock.await_args_list[1] + assert init_call.args[0] == "initGiveawayStarsRequest" + assert init_call.args[1]["payment_method"] == "usdt_ton" + assert proc_mock.await_args is not None + assert proc_mock.await_args.kwargs["payment_method"] == "usdt_ton" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("query", ["@channel", "monk", "https://t.me/id2757542991"]) +async def test_giveaway_stars_accepts_query_formats(client: FragmentClient, query: str) -> None: + call_mock = AsyncMock(return_value={"found": {}}) + with patch.object(client, "call", call_mock): + with pytest.raises(UserNotFoundError): + await client.giveaway_stars(query, winners=1, amount=500) + + search_call = call_mock.await_args_list[0] + assert search_call.args[0] == "searchStarsGiveawayRecipient" + assert search_call.args[1]["query"] == query + + @pytest.mark.asyncio async def test_giveaway_stars_channel_not_found(client: FragmentClient) -> None: with patch.object(client, "call", AsyncMock(return_value={"found": {}})): diff --git a/tests/005_test_premium.py b/tests/005_test_premium.py index ba58d55..3d111b1 100644 --- a/tests/005_test_premium.py +++ b/tests/005_test_premium.py @@ -26,6 +26,12 @@ async def test_purchase_premium_months_zero(client: FragmentClient) -> None: await client.purchase_premium("@user", months=0) +@pytest.mark.asyncio +async def test_purchase_premium_invalid_payment_method(client: FragmentClient) -> None: + with pytest.raises(ConfigurationError, match="Invalid payment method"): + await client.purchase_premium("@user", months=3, payment_method="btc") # type: ignore[arg-type] + + # Premium purchase mocked tests @@ -55,6 +61,44 @@ async def test_purchase_premium_success(client: FragmentClient) -> None: assert result.amount == 3 +@pytest.mark.asyncio +async def test_purchase_premium_passes_payment_method(client: FragmentClient) -> None: + call_mock = AsyncMock( + side_effect=[ + {"found": {"recipient": FAKE_RECIPIENT}}, + {}, # updatePremiumState + {"req_id": FAKE_REQ_ID}, + FAKE_TRANSACTION, + ] + ) + proc_mock = AsyncMock(return_value=FAKE_TX_HASH) + with ( + patch.object(client, "call", call_mock), + patch.object(_purchase_premium_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch.object(_purchase_premium_mod, "process_transaction", proc_mock), + ): + await client.purchase_premium("@user", months=6, payment_method="usdt_ton") + + init_call = call_mock.await_args_list[2] + assert init_call.args[0] == "initGiftPremiumRequest" + assert init_call.args[1]["payment_method"] == "usdt_ton" + assert proc_mock.await_args is not None + assert proc_mock.await_args.kwargs["payment_method"] == "usdt_ton" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("query", ["@user", "monk", "https://t.me/monk"]) +async def test_purchase_premium_accepts_query_formats(client: FragmentClient, query: str) -> None: + call_mock = AsyncMock(return_value={"found": {}}) + with patch.object(client, "call", call_mock): + with pytest.raises(UserNotFoundError): + await client.purchase_premium(query, months=6) + + search_call = call_mock.await_args_list[0] + assert search_call.args[0] == "searchPremiumGiftRecipient" + assert search_call.args[1]["query"] == query + + @pytest.mark.asyncio async def test_purchase_premium_user_not_found(client: FragmentClient) -> None: with patch.object(client, "call", AsyncMock(return_value={"found": {}})): @@ -89,6 +133,12 @@ async def test_giveaway_premium_invalid_months(client: FragmentClient) -> None: await client.giveaway_premium("@channel", winners=10, months=5) +@pytest.mark.asyncio +async def test_giveaway_premium_invalid_payment_method(client: FragmentClient) -> None: + with pytest.raises(ConfigurationError, match="Invalid payment method"): + await client.giveaway_premium("@channel", winners=10, months=3, payment_method="btc") # type: ignore[arg-type] + + # Premium giveaway mocked tests @@ -118,6 +168,43 @@ async def test_giveaway_premium_success(client: FragmentClient) -> None: assert result.amount == 3 +@pytest.mark.asyncio +async def test_giveaway_premium_passes_payment_method(client: FragmentClient) -> None: + call_mock = AsyncMock( + side_effect=[ + {"found": {"recipient": FAKE_RECIPIENT}}, + {"req_id": FAKE_REQ_ID}, + FAKE_TRANSACTION, + ] + ) + proc_mock = AsyncMock(return_value=FAKE_TX_HASH) + with ( + patch.object(client, "call", call_mock), + patch.object(_giveaway_premium_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch.object(_giveaway_premium_mod, "process_transaction", proc_mock), + ): + await client.giveaway_premium("@channel", winners=10, months=6, payment_method="usdt_ton") + + init_call = call_mock.await_args_list[1] + assert init_call.args[0] == "initGiveawayPremiumRequest" + assert init_call.args[1]["payment_method"] == "usdt_ton" + assert proc_mock.await_args is not None + assert proc_mock.await_args.kwargs["payment_method"] == "usdt_ton" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("query", ["@channel", "monk", "https://t.me/id2757542991"]) +async def test_giveaway_premium_accepts_query_formats(client: FragmentClient, query: str) -> None: + call_mock = AsyncMock(return_value={"found": {}}) + with patch.object(client, "call", call_mock): + with pytest.raises(UserNotFoundError): + await client.giveaway_premium(query, winners=10, months=3) + + search_call = call_mock.await_args_list[0] + assert search_call.args[0] == "searchPremiumGiveawayRecipient" + assert search_call.args[1]["query"] == query + + @pytest.mark.asyncio async def test_giveaway_premium_channel_not_found(client: FragmentClient) -> None: with patch.object(client, "call", AsyncMock(return_value={"found": {}})): diff --git a/tests/007_test_wallet.py b/tests/007_test_wallet.py index d13c081..fbfb403 100644 --- a/tests/007_test_wallet.py +++ b/tests/007_test_wallet.py @@ -1,4 +1,4 @@ -"""Unit tests for get_wallet() — wallet address and TON balance lookup.""" +"""Unit tests for get_wallet() — wallet address/state with separate TON and USDT balances.""" from unittest.mock import AsyncMock, MagicMock, patch @@ -7,7 +7,7 @@ import pytest from pyfragment import FragmentClient, WalletInfo from tests.shared import FAKE_ADDRESS, FAKE_BALANCE_NANOTON -# Wallet mocked tests +# Wallet mocked tests (TON and USDT balances are returned separately) @pytest.mark.asyncio @@ -21,6 +21,7 @@ async def test_get_wallet_returns_wallet_info(client: FragmentClient) -> None: with ( patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi, patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes, + patch("pyfragment.utils.wallet._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) @@ -31,7 +32,8 @@ async def test_get_wallet_returns_wallet_info(client: FragmentClient) -> None: assert isinstance(result, WalletInfo) assert result.address == FAKE_ADDRESS assert result.state == "active" - assert result.balance == round(FAKE_BALANCE_NANOTON / 1_000_000_000, 4) + assert result.ton_balance == round(FAKE_BALANCE_NANOTON / 1_000_000_000, 4) + assert result.usdt_balance == 12.3456 @pytest.mark.asyncio @@ -45,6 +47,7 @@ async def test_get_wallet_balance_is_zero(client: FragmentClient) -> None: with ( patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi, patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes, + patch("pyfragment.utils.wallet._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) @@ -52,5 +55,6 @@ async def test_get_wallet_balance_is_zero(client: FragmentClient) -> None: result = await client.get_wallet() - assert result.balance == 0.0 + assert result.ton_balance == 0.0 + assert result.usdt_balance == 0.0 assert result.state == "uninit" diff --git a/tests/015_test_payment_amount.py b/tests/015_test_payment_amount.py new file mode 100644 index 0000000..51eff63 --- /dev/null +++ b/tests/015_test_payment_amount.py @@ -0,0 +1,21 @@ +"""Unit tests for init payment amount parsing.""" + +from pyfragment.utils.html import parse_required_payment_amount + + +def test_parse_required_payment_amount_ton_uses_amount() -> None: + init_response = {"amount": "0.326"} + assert parse_required_payment_amount(init_response) == 0.326 + + +def test_parse_required_payment_amount_usdt_uses_amount() -> None: + init_response = { + "amount": "0.00075", + "content": '0.75', + } + assert parse_required_payment_amount(init_response) == 0.00075 + + +def test_parse_required_payment_amount_usdt_falls_back_to_amount() -> None: + init_response = {"amount": "1.25", "content": "

no usd icon

"} + assert parse_required_payment_amount(init_response) == 1.25