Refactor for GRAM (ex TON) integration

- Updated imports and references from TON to GRAM across multiple modules.
- Renamed functions and variables to reflect the change from TON to GRAM.
- Adjusted validation checks for top-up and recharge amounts to use GRAM limits.
- Modified error messages and exceptions to indicate GRAM instead of TON.
- Updated tests to ensure they reflect the new GRAM terminology and functionality.
- Introduced new constants for GRAM-related configurations.
This commit is contained in:
bohd4nx
2026-06-16 00:47:49 +03:00
parent 3481769757
commit 8f865f4911
38 changed files with 227 additions and 181 deletions
+4
View File
@@ -37,3 +37,7 @@ systests/
dist/
build/
*.egg-info/
uv.lock
#docs
*-docs/
+30
View File
@@ -24,6 +24,34 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
- Updated `DEVICE_INFO` fingerprint: Tonkeeper `appVersion` -> `26.05.0`.
- Updated client docstrings and purchase examples to document all supported payment methods.
### Renamed — TON -> GRAM (ex TON)
The TON blockchain has been rebranded to **GRAM (ex TON)**. All identifiers, messages, and documentation have been updated accordingly.
**Public API**
- `FragmentClient.topup_ton()``topup_gram()`
- `PaymentMethod.TON``PaymentMethod.GRAM`
- `PaymentMethod.USDT_TON``PaymentMethod.USDT_GRAM`
- `WalletInfo.ton_balance``WalletInfo.gram_balance`
**Constants**
- `TON_TOPUP_MIN` / `TON_TOPUP_MAX``GRAM_TOPUP_MIN` / `GRAM_TOPUP_MAX`
- `MIN_TON_BALANCE``MIN_GRAM_BALANCE`
- `USDT_TON_MASTER_ADDRESS``USDT_GRAM_MASTER_ADDRESS`
**Exceptions**
- `ConfigurationError.INVALID_TON_AMOUNT``INVALID_GRAM_AMOUNT`
- `WalletError.LOW_TON_BALANCE``LOW_GRAM_BALANCE`
- `WalletError.TON_BALANCE_CHECK_FAILED``GRAM_BALANCE_CHECK_FAILED`
**Internals**
- `pyfragment/core/constants/ton.py``gram.py`
- `check_ton_payment_balance()``check_gram_payment_balance()`
---
## [2026.3.1] — 2026-05-29
@@ -239,6 +267,8 @@ 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.3.2]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.3.2
[2026.3.1]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.3.1
[2026.3.0]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.3.0
[2026.2.3]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.2.3
[2026.2.2]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.2.2
+1 -1
View File
@@ -30,7 +30,7 @@ pyfragment/
client.py — FragmentClient (public entry point)
core/ — transport, cookies, constants
domains/ — one package per feature domain
ads/ — recharge_ads, topup_ton
ads/ — recharge_ads, topup_gram
anonymous_numbers/— buy_number, manage_number
giveaways/ — giveaway_stars, giveaway_premium
marketplace/ — search_usernames, search_numbers, search_gifts
+9 -9
View File
@@ -10,10 +10,10 @@
[![License](https://img.shields.io/github/license/bohd4nx/pyfragment?style=flat&color=blue&label=License)](LICENSE)
Async Python client for the **[Fragment](https://fragment.com)** marketplace API.
Buy Stars & Premium, run giveaways, top up TON and Ads balances,
Buy Stars & Premium, run giveaways, top up GRAM (ex TON) and Ads balances,
manage anonymous numbers, and search Fragment listings.
**[Documentation](https://bohd4nx.gitbook.io/pyfragment/)** · **[Examples](https://github.com/bohd4nx/pyfragment/tree/master/examples)** · **[Changelog](CHANGELOG.md)** · **[Donate TON](https://app.tonkeeper.com/transfer/UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5)**
**[Documentation](https://bohd4nx.gitbook.io/pyfragment/)** · **[Examples](https://github.com/bohd4nx/pyfragment/tree/master/examples)** · **[Changelog](CHANGELOG.md)** · **[Donate GRAM](https://app.tonkeeper.com/transfer/UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5)**
</div>
@@ -53,11 +53,11 @@ async def main() -> None:
},
) as client:
wallet = await client.get_wallet()
print(f"TON: {wallet.ton_balance} | USDT: {wallet.usdt_balance}")
print(f"GRAM: {wallet.gram_balance} | USDT: {wallet.usdt_balance}")
recipient = "https://t.me/username" # also: @username, username
stars = await client.purchase_stars(recipient, amount=500, payment_method=PaymentMethod.USDT_TON)
stars = await client.purchase_stars(recipient, amount=500, payment_method=PaymentMethod.USDT_GRAM)
if stars.auto_paid:
print(f"Sent {stars.amount} Stars to {stars.username} | tx: {stars.transaction_id}")
else:
@@ -67,7 +67,7 @@ async def main() -> None:
print(f"Expires: {stars.payment_expires_at}")
print(f"QR link: {stars.payment_qr_link}")
premium = await client.purchase_premium(recipient, months=6, payment_method=PaymentMethod.TON)
premium = await client.purchase_premium(recipient, months=6, payment_method=PaymentMethod.GRAM)
print(f"Sent Premium {premium.amount}m to {premium.username} | tx: {premium.transaction_id}")
@@ -80,7 +80,7 @@ asyncio.run(main())
| Parameter | Type | Default | Description |
| ---------------- | ------------- | -------- | ----------------------------------------------------------- |
| `seed` | `str` | — | 12- or 24-word TON wallet mnemonic |
| `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 |
@@ -92,7 +92,7 @@ asyncio.run(main())
### Fragment cookies
Log in to [fragment.com](https://fragment.com) and connect your TON wallet.
Log in to [fragment.com](https://fragment.com) and connect your GRAM (ex TON) wallet.
**Automatically** (recommended) — reads directly from your browser, no extension needed:
@@ -118,7 +118,7 @@ Generate at [tonconsole.com](https://tonconsole.com).
### Seed phrase
12- or 24-word mnemonic from your TON wallet (**Tonkeeper → Settings → Backup**). Never share it.
12- or 24-word mnemonic from your GRAM (ex TON) wallet (**Tonkeeper → Settings → Backup**). Never share it.
---
@@ -143,7 +143,7 @@ All exceptions inherit from `FragmentError`:
from pyfragment import (
ConfigurationError, # invalid arguments (amount, months, payment_method…)
UserNotFoundError, # recipient not found on Fragment
WalletError, # insufficient TON or USDT balance
WalletError, # insufficient GRAM (ex TON) or USDT balance
TransactionError, # broadcast failed, duplicate seqno, invalid payload
FragmentAPIError, # Fragment API returned an error response
FragmentPageError, # page fetch or hash extraction failed
+1 -1
View File
@@ -16,4 +16,4 @@ You will receive a response within 72 hours. Once the fix is released, the advis
## Scope
This library handles sensitive credentials (TON seed phrases, Fragment session cookies, Tonapi keys). Please treat any finding that could expose or misuse these credentials as high severity.
This library handles sensitive credentials (GRAM (ex TON) seed phrases, Fragment session cookies, Tonapi keys). Please treat any finding that could expose or misuse these credentials as high severity.
+2 -2
View File
@@ -1,5 +1,5 @@
"""
Example: fetch wallet address, state, and separate TON/USDT balances.
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.
@@ -34,7 +34,7 @@ async def main() -> None:
wallet = await client.get_wallet()
print(f"Address: {wallet.address}")
print(f"State: {wallet.state}")
print(f"Balance: {wallet.ton_balance} TON")
print(f"Balance: {wallet.gram_balance} GRAM (ex TON)")
print(f"Balance: {wallet.usdt_balance} USDT")
+5 -5
View File
@@ -1,8 +1,8 @@
"""
Example: recharge your own Telegram Ads account with TON.
Example: recharge your own Telegram Ads account with GRAM (ex TON).
Amount must be an integer between 1 and 1 000 000 000 TON.
Your wallet must satisfy the current minimum TON threshold and transaction cost.
Amount must be an integer between 1 and 1 000 000 000 GRAM (ex TON).
Your wallet must satisfy the current minimum GRAM (ex TON) threshold and transaction cost.
"""
import asyncio
@@ -29,7 +29,7 @@ COOKIES = {
}
ACCOUNT = "@mychannel" # channel or bot username linked to your Telegram Ads account
AMOUNT = 10 # 11 000 000 000 TON
AMOUNT = 10 # 11 000 000 000 GRAM (ex TON)
async def main() -> None:
@@ -43,7 +43,7 @@ async def main() -> None:
print(f"Invalid argument: {e}")
return
print(f"{result.amount} TON recharged to Ads account {ACCOUNT} | tx: {result.transaction_id}")
print(f"{result.amount} GRAM (ex TON) recharged to Ads account {ACCOUNT} | tx: {result.transaction_id}")
if __name__ == "__main__":
+1 -1
View File
@@ -28,7 +28,7 @@ COOKIES = {
CHANNEL = "https://t.me/channel"
WINNERS = 10 # 124 000
MONTHS = 3 # 3, 6 or 12
PAYMENT_METHOD = PaymentMethod.TON # TON, USDT_TON, USDT_ETH, USDT_POL, USDC_ETH, USDC_BASE, USDC_POL
PAYMENT_METHOD = PaymentMethod.GRAM # GRAM, USDT_GRAM, USDT_ETH, USDT_POL, USDC_ETH, USDC_BASE, USDC_POL
async def main() -> None:
+1 -1
View File
@@ -28,7 +28,7 @@ COOKIES = {
CHANNEL = "https://t.me/channel"
WINNERS = 3 # 115
AMOUNT = 1000 # 5001 000 000 stars per winner
PAYMENT_METHOD = PaymentMethod.USDT_TON # TON, USDT_TON, USDT_ETH, USDT_POL, USDC_ETH, USDC_BASE, USDC_POL
PAYMENT_METHOD = PaymentMethod.USDT_GRAM # GRAM, USDT_GRAM, USDT_ETH, USDT_POL, USDC_ETH, USDC_BASE, USDC_POL
async def main() -> None:
+1 -1
View File
@@ -27,7 +27,7 @@ COOKIES = {
USERNAME = "https://t.me/username"
MONTHS = 3 # 3, 6 or 12
PAYMENT_METHOD = PaymentMethod.TON # TON, USDT_TON, USDT_ETH, USDT_POL, USDC_ETH, USDC_BASE, USDC_POL
PAYMENT_METHOD = PaymentMethod.GRAM # GRAM, USDT_GRAM, USDT_ETH, USDT_POL, USDC_ETH, USDC_BASE, USDC_POL
async def main() -> None:
+1 -1
View File
@@ -27,7 +27,7 @@ COOKIES = {
USERNAME = "https://t.me/username"
AMOUNT = 500 # 5010 000 000 stars
PAYMENT_METHOD = PaymentMethod.USDT_TON # TON, USDT_TON, USDT_ETH, USDT_POL, USDC_ETH, USDC_BASE, USDC_POL
PAYMENT_METHOD = PaymentMethod.USDT_GRAM # GRAM, USDT_GRAM, USDT_ETH, USDT_POL, USDC_ETH, USDC_BASE, USDC_POL
async def main() -> None:
+7 -7
View File
@@ -1,10 +1,10 @@
"""
Example: top up TON to a recipient's Telegram balance.
Example: top up GRAM (ex TON) to a recipient's Telegram balance.
For adding TON to a Telegram Ads account, use recharge_ads() instead.
For adding GRAM (ex 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 satisfy the current minimum TON threshold and transaction cost.
Amount must be an integer between 1 and 1 000 000 000 GRAM (ex TON).
Your wallet must satisfy the current minimum GRAM (ex TON) threshold and transaction cost.
"""
import asyncio
@@ -31,13 +31,13 @@ COOKIES = {
}
USERNAME = "@username"
AMOUNT = 10 # 11 000 000 000 TON
AMOUNT = 10 # 11 000 000 000 GRAM (ex TON)
async def main() -> None:
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
try:
result = await client.topup_ton(USERNAME, amount=AMOUNT, show_sender=True)
result = await client.topup_gram(USERNAME, amount=AMOUNT, show_sender=True)
except UserNotFoundError:
print(f"User {USERNAME} was not found on fragment.com — check the username and try again.")
return
@@ -48,7 +48,7 @@ async def main() -> None:
print(f"Invalid argument: {e}")
return
print(f"{result.amount} TON successfully topped up for {result.username} | tx: {result.transaction_id}")
print(f"{result.amount} GRAM (ex TON) successfully topped up for {result.username} | tx: {result.transaction_id}")
if __name__ == "__main__":
+15 -15
View File
@@ -36,7 +36,7 @@ class FragmentClient:
connected with Fragment or Telegram.
Args:
seed: 12- or 24-word mnemonic phrase for the TON wallet.
seed: 12- or 24-word mnemonic phrase for the GRAM (ex TON) wallet.
api_key: Tonapi API key — get one at https://tonconsole.com.
cookies: Fragment session cookies as a dict or JSON string.
wallet_version: Wallet contract version — ``"V4R2"`` or ``"V5R1"`` (default).
@@ -140,7 +140,7 @@ class FragmentClient:
username: str,
months: int,
show_sender: bool = True,
payment_method: PaymentMethod = PaymentMethod.TON,
payment_method: PaymentMethod = PaymentMethod.GRAM,
) -> PremiumResult:
"""Gift Telegram Premium to a user.
@@ -148,7 +148,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"`` (default), ``"usdt_ton"``,
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"``.
@@ -162,7 +162,7 @@ class FragmentClient:
username: str,
amount: int,
show_sender: bool = True,
payment_method: PaymentMethod = PaymentMethod.TON,
payment_method: PaymentMethod = PaymentMethod.GRAM,
) -> StarsResult:
"""Send Telegram Stars to a user.
@@ -170,7 +170,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"`` (default), ``"usdt_ton"``,
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"``.
@@ -179,18 +179,18 @@ class FragmentClient:
"""
return await self.purchases.purchase_stars(username, amount, show_sender=show_sender, payment_method=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.
async def topup_gram(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
"""Top up GRAM (ex TON) to a recipient's Telegram balance.
Args:
username: Recipient's Telegram username (with or without ``@``).
amount: Amount in TON — integer from ``1`` to ``1 000 000 000``.
amount: Amount in GRAM (ex TON) — integer from ``1`` to ``1 000 000 000``.
show_sender: Show your name as the sender. Defaults to ``True``.
Returns:
:class:`AdsTopupResult` with ``transaction_id``, ``username``, and ``amount``.
"""
return await self.ads.topup_ton(username, amount, show_sender=show_sender)
return await self.ads.topup_gram(username, amount, show_sender=show_sender)
async def recharge_ads(self, account: str, amount: int) -> AdsRechargeResult:
"""Add funds to your own Telegram Ads account.
@@ -198,7 +198,7 @@ class FragmentClient:
Args:
account: Your Fragment Ads account identifier — the channel or bot username
the Ads account is linked to (e.g. ``"@mychannel"``).
amount: Amount in TON — integer from ``1`` to ``1 000 000 000``.
amount: Amount in GRAM (ex TON) — integer from ``1`` to ``1 000 000 000``.
Returns:
:class:`AdsRechargeResult` with ``transaction_id`` and ``amount``.
@@ -211,7 +211,7 @@ class FragmentClient:
Returns:
:class:`WalletInfo` with ``address`` (``"UQ..."``), ``state``
(``"active"``, ``"uninit"``, ``"nonexist"``, or ``"frozen"``),
``ton_balance`` in TON, and ``usdt_balance`` in USDT.
``gram_balance`` in GRAM (ex TON), and ``usdt_balance`` in USDT.
"""
return await self.tonapi.get_wallet()
@@ -220,7 +220,7 @@ class FragmentClient:
channel: str,
winners: int,
amount: int,
payment_method: PaymentMethod = PaymentMethod.TON,
payment_method: PaymentMethod = PaymentMethod.GRAM,
) -> StarsGiveawayResult:
"""Run a Telegram Stars giveaway for a channel.
@@ -228,7 +228,7 @@ 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"`` (default), ``"usdt_ton"``,
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"``.
@@ -243,7 +243,7 @@ class FragmentClient:
channel: str,
winners: int,
months: int = 3,
payment_method: PaymentMethod = PaymentMethod.TON,
payment_method: PaymentMethod = PaymentMethod.GRAM,
) -> PremiumGiveawayResult:
"""Run a Telegram Premium giveaway for a channel.
@@ -251,7 +251,7 @@ 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"`` (default), ``"usdt_ton"``,
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"``.
+8 -8
View File
@@ -1,8 +1,11 @@
from __future__ import annotations
from pyfragment.core.constants.gram import DEVICE_INFO, USDT_GRAM_MASTER_ADDRESS
from pyfragment.core.constants.headers import BASE_HEADERS, DEFAULT_TIMEOUT, REQUIRED_COOKIE_KEYS
from pyfragment.core.constants.limits import (
MIN_TON_BALANCE,
GRAM_TOPUP_MAX,
GRAM_TOPUP_MIN,
MIN_GRAM_BALANCE,
MIN_USDT_BALANCE,
MNEMONIC_WORD_COUNTS_VALID,
PREMIUM_MONTHS_VALID,
@@ -14,11 +17,8 @@ from pyfragment.core.constants.limits import (
STARS_PURCHASE_MIN,
STARS_WINNERS_MAX,
STARS_WINNERS_MIN,
TON_TOPUP_MAX,
TON_TOPUP_MIN,
TONAPI_KEY_MIN_LENGTH,
)
from pyfragment.core.constants.ton import DEVICE_INFO, USDT_TON_MASTER_ADDRESS
from pyfragment.core.constants.urls import (
ADS_TOPUP_PAGE,
FRAGMENT_BASE_URL,
@@ -39,7 +39,9 @@ __all__ = [
"FRAGMENT_BASE_URL",
"FRAGMENT_DOMAIN",
"GIFTS_PAGE",
"MIN_TON_BALANCE",
"GRAM_TOPUP_MAX",
"GRAM_TOPUP_MIN",
"MIN_GRAM_BALANCE",
"MIN_USDT_BALANCE",
"MNEMONIC_WORD_COUNTS_VALID",
"NUMBERS_PAGE",
@@ -58,7 +60,5 @@ __all__ = [
"STARS_WINNERS_MAX",
"STARS_WINNERS_MIN",
"TONAPI_KEY_MIN_LENGTH",
"TON_TOPUP_MAX",
"TON_TOPUP_MIN",
"USDT_TON_MASTER_ADDRESS",
"USDT_GRAM_MASTER_ADDRESS",
]
@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import Any
USDT_TON_MASTER_ADDRESS: str = "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs"
USDT_GRAM_MASTER_ADDRESS: str = "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs"
DEVICE_INFO: dict[str, Any] = {
"platform": "iphone",
+4 -4
View File
@@ -16,12 +16,12 @@ STARS_WINNERS_MAX: int = 15
PREMIUM_WINNERS_MIN: int = 1
PREMIUM_WINNERS_MAX: int = 24_000
# TON topup / Ads recharge amount
TON_TOPUP_MIN: int = 1
TON_TOPUP_MAX: int = 1_000_000_000
# GRAM (ex TON) topup / Ads recharge amount
GRAM_TOPUP_MIN: int = 1
GRAM_TOPUP_MAX: int = 1_000_000_000
# Wallet minimum balances
MIN_TON_BALANCE: float = 0.33
MIN_GRAM_BALANCE: float = 0.33
MIN_USDT_BALANCE: float = 0.75
# Premium subscription durations (months)
+2 -2
View File
@@ -1,5 +1,5 @@
from pyfragment.domains.ads.recharge import recharge_ads
from pyfragment.domains.ads.service import AdsService
from pyfragment.domains.ads.tonup import topup_ton
from pyfragment.domains.ads.tonup import topup_gram
__all__ = ["AdsService", "recharge_ads", "topup_ton"]
__all__ = ["AdsService", "recharge_ads", "topup_gram"]
+5 -5
View File
@@ -4,7 +4,7 @@ import json
import logging
from typing import TYPE_CHECKING
from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE_INFO, TON_TOPUP_MAX, TON_TOPUP_MIN
from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE_INFO, GRAM_TOPUP_MAX, GRAM_TOPUP_MIN
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
@@ -18,8 +18,8 @@ logger = logging.getLogger(__name__)
async def recharge_ads(client: FragmentClient, account: str, amount: int) -> AdsRechargeResult:
if not isinstance(amount, int) or not (TON_TOPUP_MIN <= amount <= TON_TOPUP_MAX):
raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT)
if not isinstance(amount, int) or not (GRAM_TOPUP_MIN <= amount <= GRAM_TOPUP_MAX):
raise ConfigurationError(ConfigurationError.INVALID_GRAM_AMOUNT)
try:
await client.call("updateAdsState", {"mode": "new"}, page_url=ADS_TOPUP_PAGE)
@@ -47,8 +47,8 @@ async def recharge_ads(client: FragmentClient, account: str, amount: int) -> Ads
return AdsRechargeResult(transaction_id=tx_hash, amount=amount)
except FragmentError as exc:
logger.error("Failed to recharge Ads account '%s' for %s TON: %s", account, amount, exc, exc_info=True)
logger.error("Failed to recharge Ads account '%s' for %s GRAM (ex TON): %s", account, amount, exc, exc_info=True)
raise
except Exception as exc:
logger.exception("Failed to recharge Ads account '%s' for %s TON due to an unexpected error", account, amount)
logger.exception("Failed to recharge Ads account '%s' for %s GRAM (ex TON) due to an unexpected error", account, amount)
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
+3 -3
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from pyfragment.domains.ads.recharge import recharge_ads
from pyfragment.domains.ads.tonup import topup_ton
from pyfragment.domains.ads.tonup import topup_gram
from pyfragment.domains.base import BaseService
from pyfragment.models.payments import AdsRechargeResult, AdsTopupResult
@@ -15,5 +15,5 @@ class AdsService(BaseService):
async def recharge_ads(self, account: str, amount: int) -> AdsRechargeResult:
return await recharge_ads(self._client, account, amount)
async def topup_ton(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
return await topup_ton(self._client, username, amount, show_sender=show_sender)
async def topup_gram(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
return await topup_gram(self._client, username, amount, show_sender=show_sender)
+11 -7
View File
@@ -4,7 +4,7 @@ import json
import logging
from typing import TYPE_CHECKING
from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE_INFO, TON_TOPUP_MAX, TON_TOPUP_MIN
from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE_INFO, GRAM_TOPUP_MAX, GRAM_TOPUP_MIN
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
@@ -25,9 +25,9 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
async def topup_ton(client: FragmentClient, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
if not isinstance(amount, int) or not (TON_TOPUP_MIN <= amount <= TON_TOPUP_MAX):
raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT)
async def topup_gram(client: FragmentClient, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
if not isinstance(amount, int) or not (GRAM_TOPUP_MIN <= amount <= GRAM_TOPUP_MAX):
raise ConfigurationError(ConfigurationError.INVALID_GRAM_AMOUNT)
try:
await client.call("updateAdsTopupState", {"mode": "new"}, page_url=ADS_TOPUP_PAGE)
@@ -41,7 +41,7 @@ async def topup_ton(client: FragmentClient, username: str, amount: int, show_sen
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="TON topup"))
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="GRAM (ex TON) topup"))
account = await get_account_info(client)
transaction = await client.call(
@@ -62,8 +62,12 @@ async def topup_ton(client: FragmentClient, username: str, amount: int, show_sen
return AdsTopupResult(transaction_id=tx_hash, username=username, amount=amount)
except FragmentError as exc:
logger.error("Failed to top up TON for user '%s' with %s TON: %s", username, amount, exc, exc_info=True)
logger.error(
"Failed to top up GRAM (ex TON) for user '%s' with %s GRAM (ex TON): %s", username, amount, exc, exc_info=True
)
raise
except Exception as exc:
logger.exception("Failed to top up TON for user '%s' with %s TON due to an unexpected error", username, amount)
logger.exception(
"Failed to top up GRAM (ex TON) for user '%s' with %s GRAM (ex TON) due to an unexpected error", username, amount
)
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
+2 -2
View File
@@ -48,7 +48,7 @@ async def giveaway_stars(
channel: str,
winners: int,
amount: int,
payment_method: PaymentMethod = PaymentMethod.TON,
payment_method: PaymentMethod = PaymentMethod.GRAM,
) -> StarsGiveawayResult:
if not isinstance(winners, int) or not (STARS_WINNERS_MIN <= winners <= STARS_WINNERS_MAX):
raise ConfigurationError(ConfigurationError.INVALID_WINNERS_STARS)
@@ -143,7 +143,7 @@ async def giveaway_premium(
channel: str,
winners: int,
months: int = 3,
payment_method: PaymentMethod = PaymentMethod.TON,
payment_method: PaymentMethod = PaymentMethod.GRAM,
) -> PremiumGiveawayResult:
if not isinstance(winners, int) or not (PREMIUM_WINNERS_MIN <= winners <= PREMIUM_WINNERS_MAX):
raise ConfigurationError(ConfigurationError.INVALID_WINNERS_PREMIUM)
+2 -2
View File
@@ -17,7 +17,7 @@ class GiveawaysService(BaseService):
channel: str,
winners: int,
amount: int,
payment_method: PaymentMethod = PaymentMethod.TON,
payment_method: PaymentMethod = PaymentMethod.GRAM,
) -> StarsGiveawayResult:
return await giveaway_stars(self._client, channel, winners, amount, payment_method=payment_method)
@@ -26,6 +26,6 @@ class GiveawaysService(BaseService):
channel: str,
winners: int,
months: int = 3,
payment_method: PaymentMethod = PaymentMethod.TON,
payment_method: PaymentMethod = PaymentMethod.GRAM,
) -> PremiumGiveawayResult:
return await giveaway_premium(self._client, channel, winners, months, payment_method=payment_method)
+2 -2
View File
@@ -45,7 +45,7 @@ async def purchase_stars(
username: str,
amount: int,
show_sender: bool = True,
payment_method: PaymentMethod = PaymentMethod.TON,
payment_method: PaymentMethod = PaymentMethod.GRAM,
) -> StarsResult:
if not isinstance(amount, int) or not (STARS_PURCHASE_MIN <= amount <= STARS_PURCHASE_MAX):
raise ConfigurationError(ConfigurationError.INVALID_STARS_AMOUNT)
@@ -128,7 +128,7 @@ async def purchase_premium(
username: str,
months: int,
show_sender: bool = True,
payment_method: PaymentMethod = PaymentMethod.TON,
payment_method: PaymentMethod = PaymentMethod.GRAM,
) -> PremiumResult:
if months not in PREMIUM_MONTHS_VALID:
raise ConfigurationError(ConfigurationError.INVALID_MONTHS)
+2 -2
View File
@@ -17,7 +17,7 @@ class PurchasesService(BaseService):
username: str,
amount: int,
show_sender: bool = True,
payment_method: PaymentMethod = PaymentMethod.TON,
payment_method: PaymentMethod = PaymentMethod.GRAM,
) -> StarsResult:
return await purchase_stars(self._client, username, amount, show_sender=show_sender, payment_method=payment_method)
@@ -26,6 +26,6 @@ class PurchasesService(BaseService):
username: str,
months: int,
show_sender: bool = True,
payment_method: PaymentMethod = PaymentMethod.TON,
payment_method: PaymentMethod = PaymentMethod.GRAM,
) -> PremiumResult:
return await purchase_premium(self._client, username, months, show_sender=show_sender, payment_method=payment_method)
+23 -23
View File
@@ -9,7 +9,7 @@ from tonutils.clients import TonapiClient
from tonutils.contracts.jetton import get_wallet_address_get_method, get_wallet_data_get_method
from tonutils.exceptions import ProviderResponseError
from pyfragment.core.constants import MIN_TON_BALANCE, MIN_USDT_BALANCE, USDT_TON_MASTER_ADDRESS
from pyfragment.core.constants import MIN_GRAM_BALANCE, MIN_USDT_BALANCE, USDT_GRAM_MASTER_ADDRESS
from pyfragment.exceptions import WalletError
from pyfragment.models.enums import WALLET_CLASSES
from pyfragment.models.wallet import WalletInfo
@@ -22,11 +22,11 @@ logger = logging.getLogger(__name__)
async def get_usdt_balance(ton: Any, wallet_address: str) -> float:
"""Return the USDT balance for a Fragment-linked TON wallet."""
"""Return the USDT balance for a Fragment-linked GRAM (ex TON) wallet."""
try:
jetton_wallet_address = await get_wallet_address_get_method(
client=ton,
address=USDT_TON_MASTER_ADDRESS,
address=USDT_GRAM_MASTER_ADDRESS,
owner_address=wallet_address,
)
wallet_data = await get_wallet_data_get_method(client=ton, address=jetton_wallet_address)
@@ -43,40 +43,40 @@ async def get_usdt_balance(ton: Any, wallet_address: str) -> float:
raise WalletError(WalletError.USDT_BALANCE_CHECK_FAILED.format(exc=exc)) from exc
async def check_ton_payment_balance(
balance_ton: float,
amount_ton: float,
async def check_gram_payment_balance(
balance_gram: float,
amount_gram: float,
required_payment_amount: float | None,
) -> None:
"""Validate that the TON wallet can cover a TON-denominated payment."""
tx_price_ton = amount_ton
"""Validate that the GRAM (ex TON) wallet can cover a GRAM (ex TON)-denominated payment."""
tx_price_gram = amount_gram
if required_payment_amount is not None and required_payment_amount > 0:
tx_price_ton = max(tx_price_ton, required_payment_amount)
tx_price_gram = max(tx_price_gram, required_payment_amount)
required_ton = max(tx_price_ton, MIN_TON_BALANCE)
if balance_ton < required_ton:
required_gram = max(tx_price_gram, MIN_GRAM_BALANCE)
if balance_gram < required_gram:
logger.error(
"Failed TON balance check: balance=%s TON, required=%s TON",
round(balance_ton, 6),
round(required_ton, 6),
"Failed GRAM (ex TON) balance check: balance=%s GRAM (ex TON), required=%s GRAM (ex TON)",
round(balance_gram, 6),
round(required_gram, 6),
)
raise WalletError(WalletError.LOW_TON_BALANCE.format(balance=balance_ton, required=required_ton))
raise WalletError(WalletError.LOW_GRAM_BALANCE.format(balance=balance_gram, required=required_gram))
async def check_usdt_payment_balance(
balance_ton: float,
balance_gram: float,
required_payment_amount: float | None,
ton: Any,
wallet_address: str,
) -> None:
"""Validate that the wallet can cover a USDT-denominated payment."""
if balance_ton < MIN_TON_BALANCE:
if balance_gram < MIN_GRAM_BALANCE:
logger.error(
"Failed TON gas reserve check for USDT payment: balance=%s TON, required=%s TON",
round(balance_ton, 6),
MIN_TON_BALANCE,
"Failed GRAM (ex TON) gas reserve check for USDT payment: balance=%s GRAM (ex TON), required=%s GRAM (ex TON)",
round(balance_gram, 6),
MIN_GRAM_BALANCE,
)
raise WalletError(WalletError.LOW_TON_BALANCE.format(balance=balance_ton, required=MIN_TON_BALANCE))
raise WalletError(WalletError.LOW_GRAM_BALANCE.format(balance=balance_gram, required=MIN_GRAM_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
@@ -109,7 +109,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 TON/USDT balances."""
"""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:
try:
wallet_cls = WALLET_CLASSES[client.wallet_version]
@@ -120,7 +120,7 @@ async def get_wallet_info(client: FragmentClient) -> WalletInfo:
return WalletInfo(
address=wallet.address.to_str(is_user_friendly=True, is_bounceable=False),
state=wallet.state.value,
ton_balance=round(wallet.balance / 1_000_000_000, 4),
gram_balance=round(wallet.balance / 1_000_000_000, 4),
usdt_balance=round(usdt_balance, 4),
)
except Exception as exc:
+14 -14
View File
@@ -11,7 +11,7 @@ from ton_core import Cell, NetworkGlobalID
from tonutils.clients import TonapiClient
from tonutils.exceptions import ProviderResponseError
from pyfragment.domains.tonapi.account import check_ton_payment_balance, check_usdt_payment_balance
from pyfragment.domains.tonapi.account import check_gram_payment_balance, check_usdt_payment_balance
from pyfragment.exceptions import ParseError, TransactionError, WalletError
from pyfragment.models.enums import WALLET_CLASSES, PaymentMethod
@@ -26,7 +26,7 @@ def clean_decode(payload: str) -> str | Cell:
"""Decode a base64 BOC comment from Fragment into text when possible.
Some Fragment payloads are plain text comments, while others are structured
TON messages such as jetton transfers. Non-text payloads are returned as a
GRAM (ex TON) messages such as jetton transfers. Non-text payloads are returned as a
`Cell` so the caller can keep the raw binary structure.
"""
s = payload.strip()
@@ -39,7 +39,7 @@ def clean_decode(payload: str) -> str | Cell:
sl = cell.begin_parse()
op = sl.load_uint(32)
if op != 0:
# Non-zero op code means this is a structured TON message, not a plain text comment.
# Non-zero op code means this is a structured GRAM (ex TON) message, not a plain text comment.
return cell
try:
return sl.load_snake_string().strip()
@@ -62,7 +62,7 @@ def _extract_message(transaction_data: dict[str, Any]) -> dict[str, Any]:
async def _check_payment_balances(
wallet: Any,
payment_method: PaymentMethod,
amount_ton: float,
amount_gram: float,
required_payment_amount: float | None,
transaction_data: dict[str, Any],
ton: Any,
@@ -70,18 +70,18 @@ async def _check_payment_balances(
"""Refresh wallet and verify sufficient balance before broadcasting."""
try:
await wallet.refresh()
balance_ton = wallet.balance / 1_000_000_000
balance_gram = wallet.balance / 1_000_000_000
if payment_method == "ton":
await check_ton_payment_balance(balance_ton, amount_ton, required_payment_amount)
await check_gram_payment_balance(balance_gram, amount_gram, required_payment_amount)
else:
# USDT is paid from the Fragment-linked wallet, not the signing wallet.
fragment_wallet_address = transaction_data["transaction"].get("from", "")
await check_usdt_payment_balance(balance_ton, required_payment_amount, ton, fragment_wallet_address)
await check_usdt_payment_balance(balance_gram, required_payment_amount, ton, fragment_wallet_address)
except WalletError:
raise
except Exception as exc:
logger.exception("Failed to validate balances before broadcasting transaction")
raise WalletError(WalletError.TON_BALANCE_CHECK_FAILED.format(exc=exc)) from exc
raise WalletError(WalletError.GRAM_BALANCE_CHECK_FAILED.format(exc=exc)) from exc
async def _broadcast_with_retry(wallet: Any, message: dict[str, Any], payload: str | Cell) -> str:
@@ -90,7 +90,7 @@ async def _broadcast_with_retry(wallet: Any, message: dict[str, Any], payload: s
try:
result = await wallet.transfer(
destination=message["address"],
amount=int(message["amount"]), # nanotons, not TON
amount=int(message["amount"]), # nanograms, not GRAM (ex TON)
body=payload,
)
return str(result.normalized_hash)
@@ -124,10 +124,10 @@ async def _broadcast_with_retry(wallet: Any, message: dict[str, Any], payload: s
async def process_transaction(
client: FragmentClient,
transaction_data: dict[str, Any],
payment_method: PaymentMethod = PaymentMethod.TON,
payment_method: PaymentMethod = PaymentMethod.GRAM,
required_payment_amount: float | None = None,
) -> str:
"""Sign and broadcast a Fragment transaction with the seeded TON wallet.
"""Sign and broadcast a Fragment transaction with the seeded GRAM (ex TON) wallet.
Args:
client: Authenticated `FragmentClient` instance.
@@ -139,13 +139,13 @@ async def process_transaction(
Normalized transaction hash string.
"""
message = _extract_message(transaction_data)
amount_ton = int(message["amount"]) / 1_000_000_000
amount_gram = int(message["amount"]) / 1_000_000_000
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
wallet_cls = WALLET_CLASSES[client.wallet_version]
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
await _check_payment_balances(wallet, payment_method, amount_ton, required_payment_amount, transaction_data, ton)
await _check_payment_balances(wallet, payment_method, amount_gram, required_payment_amount, transaction_data, ton)
payload = clean_decode(str(message.get("payload", "")))
@@ -161,7 +161,7 @@ async def process_transaction(
raise TransactionError(TransactionError.BROADCAST_FAILED_SSL.format(exc=exc)) from exc
cause = cause.__cause__ or cause.__context__
logger.exception(
"Failed to broadcast transaction to '%s' for %s nanotons using payment method '%s'",
"Failed to broadcast transaction to '%s' for %s nanograms using payment method '%s'",
message["address"],
message["amount"],
payment_method,
+13 -11
View File
@@ -1,6 +1,8 @@
from __future__ import annotations
from pyfragment.core.constants.limits import (
GRAM_TOPUP_MAX,
GRAM_TOPUP_MIN,
MNEMONIC_WORD_COUNTS_VALID,
PREMIUM_MONTHS_VALID,
PREMIUM_WINNERS_MAX,
@@ -11,8 +13,6 @@ from pyfragment.core.constants.limits import (
STARS_PURCHASE_MIN,
STARS_WINNERS_MAX,
STARS_WINNERS_MIN,
TON_TOPUP_MAX,
TON_TOPUP_MIN,
TONAPI_KEY_MIN_LENGTH,
)
@@ -39,7 +39,7 @@ class ConfigurationError(ClientError):
INVALID_STARS_AMOUNT = (
f"Invalid Stars amount: must be an integer between {STARS_PURCHASE_MIN:,} and {STARS_PURCHASE_MAX:,}."
)
INVALID_TON_AMOUNT = f"Invalid TON amount: must be an integer between {TON_TOPUP_MIN:,} and {TON_TOPUP_MAX:,}."
INVALID_GRAM_AMOUNT = f"Invalid GRAM (ex TON) amount: must be an integer between {GRAM_TOPUP_MIN:,} and {GRAM_TOPUP_MAX:,}."
INVALID_USERNAME = (
"Invalid username '{username}'. "
"Must be 5-32 characters and contain only letters (A-Z, a-z), digits (0-9), or underscores (_)."
@@ -70,7 +70,7 @@ class CookieError(ClientError):
)
MISSING_BROWSER_KEYS = (
"Fragment cookies not found in {browser}: {keys}. "
"Make sure you are logged in to {url} and have connected your TON wallet in {browser}."
"Make sure you are logged in to {url} and have connected your GRAM (ex TON) wallet in {browser}."
)
EXPIRED = "Fragment session cookie expired at {expires}. Log in to fragment.com in your browser and extract fresh cookies."
@@ -111,7 +111,7 @@ class AnonymousNumberError(FragmentAPIError):
class TransactionError(FragmentAPIError):
"""Raised when a TON transaction fails to build or broadcast."""
"""Raised when a GRAM (ex TON) transaction fails to build or broadcast."""
INVALID_PAYLOAD = "Fragment returned an invalid transaction payload: 'transaction.messages' is missing or empty."
BROADCAST_FAILED = "Transaction broadcast failed: {exc}"
@@ -123,7 +123,7 @@ class TransactionError(FragmentAPIError):
"located in your Python installation folder."
)
DUPLICATE_SEQNO = (
"Transaction broadcast failed: the TON wallet rejected the message "
"Transaction broadcast failed: the GRAM (ex TON) wallet rejected the message "
"because a previous transaction with the same sequence number (seqno) "
"is still pending confirmation on-chain.\n"
"Wait a few seconds for the previous transaction to confirm, then retry."
@@ -150,14 +150,16 @@ class OperationError(FragmentError):
class WalletError(OperationError):
"""Raised for TON wallet issues (connection, balance, account info)."""
"""Raised for GRAM (ex TON) wallet issues (connection, balance, account info)."""
LOW_TON_BALANCE = "Insufficient TON balance: {balance:.4f} TON available, {required:.4f} TON required."
LOW_GRAM_BALANCE = (
"Insufficient GRAM (ex TON) balance: {balance:.4f} GRAM (ex TON) available, {required:.4f} GRAM (ex 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}"
GRAM_BALANCE_CHECK_FAILED = "Failed to fetch GRAM (ex 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}"
ACCOUNT_INFO_FAILED = "Failed to retrieve wallet account info from GRAM (ex TON) network: {exc}"
WALLET_INFO_FAILED = "Failed to retrieve wallet info from GRAM (ex TON) network: {exc}"
class UnexpectedError(OperationError):
+2 -2
View File
@@ -15,8 +15,8 @@ else:
class PaymentMethod(StrEnum):
TON = "ton"
USDT_TON = "usdt_ton"
GRAM = "ton"
USDT_GRAM = "usdt_ton"
# Not supported yet
USDT_ETH = "usdt_eth"
+2 -2
View File
@@ -30,7 +30,7 @@ class AdsTopupResult:
amount: int
def __repr__(self) -> str:
return f"AdsTopupResult(username='{self.username}', amount={self.amount} TON, tx='{self.transaction_id}')"
return f"AdsTopupResult(username='{self.username}', amount={self.amount} GRAM (ex TON), tx='{self.transaction_id}')"
@dataclass
@@ -39,7 +39,7 @@ class AdsRechargeResult:
amount: int
def __repr__(self) -> str:
return f"AdsRechargeResult(amount={self.amount} TON, tx='{self.transaction_id}')"
return f"AdsRechargeResult(amount={self.amount} GRAM (ex TON), tx='{self.transaction_id}')"
__all__ = ["AdsRechargeResult", "AdsTopupResult", "PremiumResult", "StarsResult"]
+2 -2
View File
@@ -7,13 +7,13 @@ from dataclasses import dataclass
class WalletInfo:
address: str
state: str
ton_balance: float
gram_balance: float
usdt_balance: float
def __repr__(self) -> str:
return (
f"WalletInfo(address='{self.address}', state='{self.state}', "
f"ton_balance={self.ton_balance} TON, usdt_balance={self.usdt_balance} USDT)"
f"gram_balance={self.gram_balance} GRAM (ex TON), usdt_balance={self.usdt_balance} USDT)"
)
+9 -3
View File
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
[project]
name = "pyfragment"
version = "2026.3.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."
description = "Async Python client for the Fragment API. Buy Stars and Premium, top up GRAM (ex TON) and Ads balances, run giveaways, manage anonymous numbers, and search Fragment listings."
readme = "README.md"
license = { text = "MIT" }
requires-python = ">=3.10"
@@ -91,10 +91,16 @@ ignore = ["E501", "UP017"]
[tool.mypy]
python_version = "3.10"
strict = true
exclude = ["^examples/", "^logs/"]
exclude = ["^examples/", "^logs/", "^tests/"]
[[tool.mypy.overrides]]
module = "rookiepy"
module = [
"rookiepy",
"httpx",
"ton_core",
"tonutils",
"tonutils.*",
]
ignore_missing_imports = true
[[tool.mypy.overrides]]
+10 -10
View File
@@ -1,4 +1,4 @@
"""Exercise transaction signing, balance checks, and retry behavior for TON and USDT flows."""
"""Exercise transaction signing, balance checks, and retry behavior for GRAM (ex TON) and USDT flows."""
from collections.abc import Generator
from contextlib import contextmanager
@@ -22,7 +22,7 @@ TRANSACTION_DATA = {
"messages": [
{
"address": "0:852443f8599fe6a5da34fe43049ac4e0beb3071bb2bfb56635ea9421287c283a",
"amount": "500000000", # 0.5 TON
"amount": "500000000", # 0.5 GRAM
"payload": "",
}
]
@@ -63,7 +63,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, above threshold
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"):
result = await process_transaction(_make_client(), TRANSACTION_DATA)
assert result == "abc123"
@@ -72,7 +72,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, below threshold
wallet = _make_wallet(balance_nanotons=100_000_000) # 0.1 GRAM, below threshold
with _patch_wallet(wallet):
with pytest.raises(WalletError, match="required"):
await process_transaction(_make_client(), TRANSACTION_DATA)
@@ -89,7 +89,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=499_999_999) # 1 nanoton below transaction amount threshold
wallet = _make_wallet(balance_nanotons=499_999_999) # 1 nanogram below transaction amount threshold
with _patch_wallet(wallet):
with pytest.raises(WalletError, match="required"):
await process_transaction(_make_client(), TRANSACTION_DATA)
@@ -142,11 +142,11 @@ async def test_duplicate_seqno_raises_after_retries() -> None:
@pytest.mark.asyncio
async def test_usdt_payment_requires_min_ton_gas_reserve() -> None:
wallet = _make_wallet(balance_nanotons=10_000_000) # 0.01 TON below MIN_TON_BALANCE
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 pytest.raises(WalletError, match="Insufficient TON balance"):
await process_transaction(_make_client(), TRANSACTION_DATA, payment_method=PaymentMethod.USDT_TON)
with pytest.raises(WalletError, match="Insufficient GRAM"):
await process_transaction(_make_client(), TRANSACTION_DATA, payment_method=PaymentMethod.USDT_GRAM)
@pytest.mark.asyncio
@@ -174,6 +174,6 @@ async def test_usdt_payment_checks_usdt_balance() -> None:
await process_transaction(
_make_client(),
transaction,
payment_method=PaymentMethod.USDT_TON,
payment_method=PaymentMethod.USDT_GRAM,
required_payment_amount=12.5,
)
+2 -2
View File
@@ -87,7 +87,7 @@ async def test_purchase_stars_passes_payment_method(client: FragmentClient) -> N
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=PaymentMethod.USDT_TON)
await client.purchase_stars("@user", amount=500, payment_method=PaymentMethod.USDT_GRAM)
init_call = call_mock.await_args_list[2]
assert init_call.args[0] == "initBuyStarsRequest"
@@ -209,7 +209,7 @@ async def test_giveaway_stars_passes_payment_method(client: FragmentClient) -> N
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=PaymentMethod.USDT_TON)
await client.giveaway_stars("@channel", winners=3, amount=1000, payment_method=PaymentMethod.USDT_GRAM)
init_call = call_mock.await_args_list[3]
assert init_call.args[0] == "initGiveawayStarsRequest"
+2 -2
View File
@@ -78,7 +78,7 @@ async def test_purchase_premium_passes_payment_method(client: FragmentClient) ->
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=PaymentMethod.USDT_TON)
await client.purchase_premium("@user", months=6, payment_method=PaymentMethod.USDT_GRAM)
init_call = call_mock.await_args_list[2]
assert init_call.args[0] == "initGiftPremiumRequest"
@@ -207,7 +207,7 @@ async def test_giveaway_premium_passes_payment_method(client: FragmentClient) ->
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=PaymentMethod.USDT_TON)
await client.giveaway_premium("@channel", winners=10, months=6, payment_method=PaymentMethod.USDT_GRAM)
init_call = call_mock.await_args_list[3]
assert init_call.args[0] == "initGiveawayPremiumRequest"
+17 -17
View File
@@ -1,40 +1,40 @@
"""Cover TON top-up through Telegram Ads, including recipient lookup and transaction building."""
"""Cover GRAM (ex TON) top-up through Telegram Ads, including recipient lookup and transaction building."""
from unittest.mock import AsyncMock, patch
import pytest
import pyfragment.domains.ads.tonup as _topup_ton_mod
import pyfragment.domains.ads.tonup as _topup_gram_mod
from pyfragment import AdsTopupResult, ConfigurationError, FragmentClient, UserNotFoundError
from pyfragment.core.constants.limits import TON_TOPUP_MAX, TON_TOPUP_MIN
from pyfragment.core.constants.limits import GRAM_TOPUP_MAX, GRAM_TOPUP_MIN
from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
# Topup TON validation tests
# Topup GRAM (ex TON) validation tests
@pytest.mark.asyncio
async def test_topup_ton_amount_zero(client: FragmentClient) -> None:
async def test_topup_gram_amount_zero(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError):
await client.topup_ton("@user", amount=TON_TOPUP_MIN - 1)
await client.topup_gram("@user", amount=GRAM_TOPUP_MIN - 1)
@pytest.mark.asyncio
async def test_topup_ton_amount_too_high(client: FragmentClient) -> None:
async def test_topup_gram_amount_too_high(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError):
await client.topup_ton("@user", amount=TON_TOPUP_MAX + 1)
await client.topup_gram("@user", amount=GRAM_TOPUP_MAX + 1)
@pytest.mark.asyncio
async def test_topup_ton_float_amount(client: FragmentClient) -> None:
async def test_topup_gram_float_amount(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError):
await client.topup_ton("@user", amount=1.5) # type: ignore[arg-type]
await client.topup_gram("@user", amount=1.5) # type: ignore[arg-type]
# Topup TON mocked tests
# Topup GRAM (ex TON) mocked tests
@pytest.mark.asyncio
async def test_topup_ton_success(client: FragmentClient) -> None:
async def test_topup_gram_success(client: FragmentClient) -> None:
with (
patch.object(
client,
@@ -48,10 +48,10 @@ async def test_topup_ton_success(client: FragmentClient) -> None:
]
),
),
patch.object(_topup_ton_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
patch.object(_topup_ton_mod, "process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
patch.object(_topup_gram_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
patch.object(_topup_gram_mod, "process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
):
result = await client.topup_ton("@user", amount=10)
result = await client.topup_gram("@user", amount=10)
assert isinstance(result, AdsTopupResult)
assert result.transaction_id == FAKE_TX_HASH
@@ -60,7 +60,7 @@ async def test_topup_ton_success(client: FragmentClient) -> None:
@pytest.mark.asyncio
async def test_topup_ton_user_not_found(client: FragmentClient) -> None:
async def test_topup_gram_user_not_found(client: FragmentClient) -> None:
with patch.object(
client,
"call",
@@ -72,4 +72,4 @@ async def test_topup_ton_user_not_found(client: FragmentClient) -> None:
),
):
with pytest.raises(UserNotFoundError):
await client.topup_ton("@ghost", amount=10)
await client.topup_gram("@ghost", amount=10)
+6 -6
View File
@@ -1,20 +1,20 @@
"""Verify wallet inspection returns friendly TON and USDT balances from Tonapi."""
"""Verify wallet inspection returns friendly GRAM (ex TON) and USDT balances from Tonapi."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from pyfragment import FragmentClient, WalletInfo
from tests.shared import FAKE_ADDRESS, FAKE_BALANCE_NANOTON
from tests.shared import FAKE_ADDRESS, FAKE_BALANCE_NANOGRAM
# Wallet mocked tests (TON and USDT balances are returned separately)
# Wallet mocked tests (GRAM and USDT balances are returned separately)
@pytest.mark.asyncio
async def test_get_wallet_returns_wallet_info(client: FragmentClient) -> None:
mock_wallet = MagicMock()
mock_wallet.refresh = AsyncMock()
mock_wallet.balance = FAKE_BALANCE_NANOTON
mock_wallet.balance = FAKE_BALANCE_NANOGRAM
mock_wallet.state = MagicMock(value="active")
mock_wallet.address.to_str.return_value = FAKE_ADDRESS
@@ -32,7 +32,7 @@ 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.ton_balance == round(FAKE_BALANCE_NANOTON / 1_000_000_000, 4)
assert result.gram_balance == round(FAKE_BALANCE_NANOGRAM / 1_000_000_000, 4)
assert result.usdt_balance == 12.3456
@@ -55,6 +55,6 @@ async def test_get_wallet_balance_is_zero(client: FragmentClient) -> None:
result = await client.get_wallet()
assert result.ton_balance == 0.0
assert result.gram_balance == 0.0
assert result.usdt_balance == 0.0
assert result.state == "uninit"
+3 -3
View File
@@ -6,7 +6,7 @@ import pytest
import pyfragment.domains.ads.recharge as _recharge_ads_mod
from pyfragment import AdsRechargeResult, ConfigurationError, FragmentClient
from pyfragment.core.constants.limits import TON_TOPUP_MAX, TON_TOPUP_MIN
from pyfragment.core.constants.limits import GRAM_TOPUP_MAX, GRAM_TOPUP_MIN
from tests.shared import FAKE_ACCOUNT, FAKE_ADS_ACCOUNT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
# recharge_ads validation tests
@@ -15,13 +15,13 @@ from tests.shared import FAKE_ACCOUNT, FAKE_ADS_ACCOUNT, FAKE_REQ_ID, FAKE_TRANS
@pytest.mark.asyncio
async def test_recharge_ads_amount_zero(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError):
await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=TON_TOPUP_MIN - 1)
await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=GRAM_TOPUP_MIN - 1)
@pytest.mark.asyncio
async def test_recharge_ads_amount_too_high(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError):
await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=TON_TOPUP_MAX + 1)
await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=GRAM_TOPUP_MAX + 1)
@pytest.mark.asyncio
+2 -2
View File
@@ -1,7 +1,7 @@
"""Shared test constants for the pyfragment test suite.
pyfragment is an async Python client for the Fragment API a unified toolkit
to manage Telegram assets: purchase Stars and Premium, top up TON and Ads balances,
to manage Telegram assets: purchase Stars and Premium, top up GRAM (ex TON) and Ads balances,
run giveaways, manage anonymous numbers, and explore the marketplace for usernames,
numbers, and gifts.
"""
@@ -31,7 +31,7 @@ FAKE_RESPONSE: dict[str, Any] = {"status": "ok", "data": {"value": 42}}
# get_wallet()
FAKE_ADDRESS: str = "UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5"
FAKE_BALANCE_NANOTON: int = 1_500_000_000 # 1.5 TON
FAKE_BALANCE_NANOGRAM: int = 1_500_000_000 # 1.5 GRAM (ex TON)
# recharge_ads
FAKE_ADS_ACCOUNT: str = "@mychannel"