mirror of
https://github.com/bohd4nx/FragmentAPI.git
synced 2026-07-25 06:14:29 +00:00
Bump version to 2026.2.3 and refactor wallet utilities
- Updated version in pyproject.toml to 2026.2.3. - Refactored wallet utilities: - Moved `clean_decode` and `process_transaction` to `transaction.py`. - Created `balance.py` for balance-related functions. - Created `info.py` for wallet information retrieval. - Created `transfer.py` for sending TON and USDT transfers. - Updated tests to reflect new module structure and imports. - Added new API utility functions for handling Fragment API requests.
This commit is contained in:
@@ -7,6 +7,20 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [2026.2.3] — 2026-05-12
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed USDT payment flow: the USDT balance check now correctly targets the wallet linked to the Fragment account (`transaction["from"]`), not the signing seed wallet. These are two distinct addresses — the seed wallet only signs the transaction and covers TON gas fees, while USDT is withdrawn from the Fragment-linked wallet.
|
||||||
|
- Fixed `clean_decode()` incorrectly treating binary TON cell payloads (e.g. jetton transfer messages with non-zero op codes) as text comments. Only cells with op code `0x00000000` are now decoded as snake-encoded UTF-8 strings; all other op codes return the raw `Cell` as-is.
|
||||||
|
- Restored and correctly wired USDT balance validation so `WalletError` is raised before broadcasting when the Fragment-linked wallet has insufficient USDT.
|
||||||
|
|
||||||
|
### Note
|
||||||
|
|
||||||
|
- USDT (`usdt_ton`) payments require USDT to be held in the TON wallet that is linked to your Fragment account profile. The seed wallet configured in `FragmentClient` is only used to sign transactions and pay TON network fees.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [2026.2.2] — 2026-05-11
|
## [2026.2.2] — 2026-05-11
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -163,6 +177,7 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
|
|||||||
- `py.typed` marker — full PEP 561 typing support for type-checkers
|
- `py.typed` marker — full PEP 561 typing support for type-checkers
|
||||||
- `__repr__` on all result types for readable debug output
|
- `__repr__` on all result types for readable debug output
|
||||||
|
|
||||||
|
[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
|
[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.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.2.0]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.2.0
|
||||||
|
|||||||
+20
-16
@@ -1,20 +1,24 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from typing import Any, cast
|
from typing import Any, cast, get_args
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from pyfragment.methods.anonymous_number import get_login_code, terminate_sessions, toggle_login_codes
|
from pyfragment.methods import (
|
||||||
from pyfragment.methods.giveaway_premium import giveaway_premium
|
get_login_code,
|
||||||
from pyfragment.methods.giveaway_stars import giveaway_stars
|
giveaway_premium,
|
||||||
from pyfragment.methods.purchase_premium import purchase_premium
|
giveaway_stars,
|
||||||
from pyfragment.methods.purchase_stars import purchase_stars
|
purchase_premium,
|
||||||
from pyfragment.methods.recharge_ads import recharge_ads
|
purchase_stars,
|
||||||
from pyfragment.methods.search_gifts import search_gifts
|
recharge_ads,
|
||||||
from pyfragment.methods.search_numbers import search_numbers
|
search_gifts,
|
||||||
from pyfragment.methods.search_usernames import search_usernames
|
search_numbers,
|
||||||
from pyfragment.methods.topup_ton import topup_ton
|
search_usernames,
|
||||||
|
terminate_sessions,
|
||||||
|
toggle_login_codes,
|
||||||
|
topup_ton,
|
||||||
|
)
|
||||||
from pyfragment.types import (
|
from pyfragment.types import (
|
||||||
AdsRechargeResult,
|
AdsRechargeResult,
|
||||||
AdsTopupResult,
|
AdsTopupResult,
|
||||||
@@ -32,14 +36,14 @@ from pyfragment.types import (
|
|||||||
WalletInfo,
|
WalletInfo,
|
||||||
)
|
)
|
||||||
from pyfragment.types.constants import (
|
from pyfragment.types.constants import (
|
||||||
|
BASE_HEADERS,
|
||||||
DEFAULT_TIMEOUT,
|
DEFAULT_TIMEOUT,
|
||||||
FRAGMENT_BASE_URL,
|
FRAGMENT_BASE_URL,
|
||||||
REQUIRED_COOKIE_KEYS,
|
REQUIRED_COOKIE_KEYS,
|
||||||
SUPPORTED_WALLET_VERSIONS,
|
|
||||||
PaymentMethod,
|
PaymentMethod,
|
||||||
WalletVersion,
|
WalletVersion,
|
||||||
)
|
)
|
||||||
from pyfragment.utils.http import fragment_request, get_fragment_hash, make_headers
|
from pyfragment.utils.api import fragment_request, get_fragment_hash
|
||||||
from pyfragment.utils.wallet import get_wallet_info
|
from pyfragment.utils.wallet import get_wallet_info
|
||||||
|
|
||||||
|
|
||||||
@@ -104,10 +108,10 @@ class FragmentClient:
|
|||||||
raise CookieError(CookieError.MISSING_KEYS.format(keys=", ".join(missing_keys)))
|
raise CookieError(CookieError.MISSING_KEYS.format(keys=", ".join(missing_keys)))
|
||||||
|
|
||||||
version = wallet_version.strip().upper()
|
version = wallet_version.strip().upper()
|
||||||
if version not in SUPPORTED_WALLET_VERSIONS:
|
if version not in get_args(WalletVersion):
|
||||||
raise ConfigurationError(
|
raise ConfigurationError(
|
||||||
ConfigurationError.UNSUPPORTED_VERSION.format(
|
ConfigurationError.UNSUPPORTED_VERSION.format(
|
||||||
version=version, supported=", ".join(sorted(SUPPORTED_WALLET_VERSIONS))
|
version=version, supported=", ".join(sorted(get_args(WalletVersion)))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -386,7 +390,7 @@ class FragmentClient:
|
|||||||
page_url="https://fragment.com/premium/gift",
|
page_url="https://fragment.com/premium/gift",
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
headers = make_headers(page_url)
|
headers = {**BASE_HEADERS, "referer": page_url, "x-aj-referer": page_url}
|
||||||
async with httpx.AsyncClient(cookies=self.cookies, timeout=self.timeout) as session:
|
async with httpx.AsyncClient(cookies=self.cookies, timeout=self.timeout) as session:
|
||||||
fragment_hash = await get_fragment_hash(self.cookies, headers, page_url, self.timeout)
|
fragment_hash = await get_fragment_hash(self.cookies, headers, page_url, self.timeout)
|
||||||
return await fragment_request(session, fragment_hash, headers, {"method": method, **(data or {})})
|
return await fragment_request(session, fragment_hash, headers, {"method": method, **(data or {})})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, get_args
|
||||||
|
|
||||||
from pyfragment.types import (
|
from pyfragment.types import (
|
||||||
ConfigurationError,
|
ConfigurationError,
|
||||||
@@ -12,7 +12,7 @@ from pyfragment.types import (
|
|||||||
UserNotFoundError,
|
UserNotFoundError,
|
||||||
VerificationError,
|
VerificationError,
|
||||||
)
|
)
|
||||||
from pyfragment.types.constants import DEVICE, PREMIUM_GIVEAWAY_PAGE, SUPPORTED_PAYMENT_METHODS, PaymentMethod
|
from pyfragment.types.constants import DEVICE, PREMIUM_GIVEAWAY_PAGE, PaymentMethod
|
||||||
from pyfragment.utils import get_account_info, parse_required_payment_amount, process_transaction
|
from pyfragment.utils import get_account_info, parse_required_payment_amount, process_transaction
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -49,11 +49,11 @@ async def giveaway_premium(
|
|||||||
raise ConfigurationError(ConfigurationError.INVALID_WINNERS_PREMIUM)
|
raise ConfigurationError(ConfigurationError.INVALID_WINNERS_PREMIUM)
|
||||||
if months not in (3, 6, 12):
|
if months not in (3, 6, 12):
|
||||||
raise ConfigurationError(ConfigurationError.INVALID_MONTHS)
|
raise ConfigurationError(ConfigurationError.INVALID_MONTHS)
|
||||||
if payment_method not in SUPPORTED_PAYMENT_METHODS:
|
if payment_method not in get_args(PaymentMethod):
|
||||||
raise ConfigurationError(
|
raise ConfigurationError(
|
||||||
ConfigurationError.INVALID_PAYMENT_METHOD.format(
|
ConfigurationError.INVALID_PAYMENT_METHOD.format(
|
||||||
method=payment_method,
|
method=payment_method,
|
||||||
supported=", ".join(sorted(SUPPORTED_PAYMENT_METHODS)),
|
supported=", ".join(sorted(get_args(PaymentMethod))),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, get_args
|
||||||
|
|
||||||
from pyfragment.types import (
|
from pyfragment.types import (
|
||||||
ConfigurationError,
|
ConfigurationError,
|
||||||
@@ -12,7 +12,7 @@ from pyfragment.types import (
|
|||||||
UserNotFoundError,
|
UserNotFoundError,
|
||||||
VerificationError,
|
VerificationError,
|
||||||
)
|
)
|
||||||
from pyfragment.types.constants import DEVICE, STARS_GIVEAWAY_PAGE, SUPPORTED_PAYMENT_METHODS, PaymentMethod
|
from pyfragment.types.constants import DEVICE, STARS_GIVEAWAY_PAGE, PaymentMethod
|
||||||
from pyfragment.utils import get_account_info, parse_required_payment_amount, process_transaction
|
from pyfragment.utils import get_account_info, parse_required_payment_amount, process_transaction
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -49,11 +49,11 @@ async def giveaway_stars(
|
|||||||
raise ConfigurationError(ConfigurationError.INVALID_WINNERS_STARS)
|
raise ConfigurationError(ConfigurationError.INVALID_WINNERS_STARS)
|
||||||
if not isinstance(amount, int) or not (500 <= amount <= 1_000_000):
|
if not isinstance(amount, int) or not (500 <= amount <= 1_000_000):
|
||||||
raise ConfigurationError(ConfigurationError.INVALID_STARS_PER_WINNER)
|
raise ConfigurationError(ConfigurationError.INVALID_STARS_PER_WINNER)
|
||||||
if payment_method not in SUPPORTED_PAYMENT_METHODS:
|
if payment_method not in get_args(PaymentMethod):
|
||||||
raise ConfigurationError(
|
raise ConfigurationError(
|
||||||
ConfigurationError.INVALID_PAYMENT_METHOD.format(
|
ConfigurationError.INVALID_PAYMENT_METHOD.format(
|
||||||
method=payment_method,
|
method=payment_method,
|
||||||
supported=", ".join(sorted(SUPPORTED_PAYMENT_METHODS)),
|
supported=", ".join(sorted(get_args(PaymentMethod))),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, get_args
|
||||||
|
|
||||||
from pyfragment.types import (
|
from pyfragment.types import (
|
||||||
ConfigurationError,
|
ConfigurationError,
|
||||||
@@ -13,7 +13,7 @@ from pyfragment.types import (
|
|||||||
UserNotFoundError,
|
UserNotFoundError,
|
||||||
VerificationError,
|
VerificationError,
|
||||||
)
|
)
|
||||||
from pyfragment.types.constants import DEVICE, PREMIUM_PAGE, SUPPORTED_PAYMENT_METHODS, PaymentMethod
|
from pyfragment.types.constants import DEVICE, PREMIUM_PAGE, PaymentMethod
|
||||||
from pyfragment.utils import get_account_info, parse_required_payment_amount, process_transaction
|
from pyfragment.utils import get_account_info, parse_required_payment_amount, process_transaction
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -47,11 +47,11 @@ async def purchase_premium(
|
|||||||
"""
|
"""
|
||||||
if months not in (3, 6, 12):
|
if months not in (3, 6, 12):
|
||||||
raise ConfigurationError(ConfigurationError.INVALID_MONTHS)
|
raise ConfigurationError(ConfigurationError.INVALID_MONTHS)
|
||||||
if payment_method not in SUPPORTED_PAYMENT_METHODS:
|
if payment_method not in get_args(PaymentMethod):
|
||||||
raise ConfigurationError(
|
raise ConfigurationError(
|
||||||
ConfigurationError.INVALID_PAYMENT_METHOD.format(
|
ConfigurationError.INVALID_PAYMENT_METHOD.format(
|
||||||
method=payment_method,
|
method=payment_method,
|
||||||
supported=", ".join(sorted(SUPPORTED_PAYMENT_METHODS)),
|
supported=", ".join(sorted(get_args(PaymentMethod))),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, get_args
|
||||||
|
|
||||||
from pyfragment.types import (
|
from pyfragment.types import (
|
||||||
ConfigurationError,
|
ConfigurationError,
|
||||||
@@ -13,7 +13,7 @@ from pyfragment.types import (
|
|||||||
UserNotFoundError,
|
UserNotFoundError,
|
||||||
VerificationError,
|
VerificationError,
|
||||||
)
|
)
|
||||||
from pyfragment.types.constants import DEVICE, STARS_PAGE, SUPPORTED_PAYMENT_METHODS, PaymentMethod
|
from pyfragment.types.constants import DEVICE, STARS_PAGE, PaymentMethod
|
||||||
from pyfragment.utils import get_account_info, parse_required_payment_amount, process_transaction
|
from pyfragment.utils import get_account_info, parse_required_payment_amount, process_transaction
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -43,11 +43,11 @@ async def purchase_stars(
|
|||||||
"""
|
"""
|
||||||
if not isinstance(amount, int) or not (50 <= amount <= 1_000_000):
|
if not isinstance(amount, int) or not (50 <= amount <= 1_000_000):
|
||||||
raise ConfigurationError(ConfigurationError.INVALID_STARS_AMOUNT)
|
raise ConfigurationError(ConfigurationError.INVALID_STARS_AMOUNT)
|
||||||
if payment_method not in SUPPORTED_PAYMENT_METHODS:
|
if payment_method not in get_args(PaymentMethod):
|
||||||
raise ConfigurationError(
|
raise ConfigurationError(
|
||||||
ConfigurationError.INVALID_PAYMENT_METHOD.format(
|
ConfigurationError.INVALID_PAYMENT_METHOD.format(
|
||||||
method=payment_method,
|
method=payment_method,
|
||||||
supported=", ".join(sorted(SUPPORTED_PAYMENT_METHODS)),
|
supported=", ".join(sorted(get_args(PaymentMethod))),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ from pyfragment.types.results import (
|
|||||||
StarsGiveawayResult,
|
StarsGiveawayResult,
|
||||||
StarsResult,
|
StarsResult,
|
||||||
TerminateSessionsResult,
|
TerminateSessionsResult,
|
||||||
|
TonTransferResult,
|
||||||
|
UsdtTransferResult,
|
||||||
UsernamesResult,
|
UsernamesResult,
|
||||||
WalletInfo,
|
WalletInfo,
|
||||||
)
|
)
|
||||||
@@ -60,6 +62,8 @@ __all__ = [
|
|||||||
"StarsGiveawayResult",
|
"StarsGiveawayResult",
|
||||||
"StarsResult",
|
"StarsResult",
|
||||||
"TerminateSessionsResult",
|
"TerminateSessionsResult",
|
||||||
|
"TonTransferResult",
|
||||||
|
"UsdtTransferResult",
|
||||||
"UsernamesResult",
|
"UsernamesResult",
|
||||||
"WalletInfo",
|
"WalletInfo",
|
||||||
# literal types
|
# literal types
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from typing import Any, Literal, get_args
|
from typing import Any, Literal
|
||||||
|
|
||||||
from tonutils.contracts.wallet import WalletV4R2, WalletV5R1
|
from tonutils.contracts.wallet import WalletV4R2, WalletV5R1
|
||||||
|
|
||||||
# Payment methods
|
# Payment methods
|
||||||
PaymentMethod = Literal["ton", "usdt_ton"]
|
PaymentMethod = Literal["ton", "usdt_ton"]
|
||||||
SUPPORTED_PAYMENT_METHODS: frozenset[str] = frozenset(get_args(PaymentMethod))
|
|
||||||
|
|
||||||
# Single source of truth for supported wallet versions
|
# Single source of truth for supported wallet versions
|
||||||
WalletVersion = Literal["V4R2", "V5R1"]
|
WalletVersion = Literal["V4R2", "V5R1"]
|
||||||
SUPPORTED_WALLET_VERSIONS: frozenset[str] = frozenset(get_args(WalletVersion))
|
|
||||||
|
|
||||||
# Wallet class map — used to resolve the correct contract from WALLET_VERSION
|
# Wallet class map — used to resolve the correct contract from WALLET_VERSION
|
||||||
WALLET_CLASSES: dict[str, Any] = {"V4R2": WalletV4R2, "V5R1": WalletV5R1}
|
WALLET_CLASSES: dict[str, Any] = {"V4R2": WalletV4R2, "V5R1": WalletV5R1}
|
||||||
|
|||||||
@@ -206,6 +206,30 @@ class GiftsResult:
|
|||||||
return f"GiftsResult(items={len(self.items)}, next_offset={self.next_offset!r})"
|
return f"GiftsResult(items={len(self.items)}, next_offset={self.next_offset!r})"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TonTransferResult:
|
||||||
|
"""Result of a direct TON transfer via :meth:`FragmentClient.send_ton`."""
|
||||||
|
|
||||||
|
transaction_id: str
|
||||||
|
destination: str
|
||||||
|
amount: int
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"TonTransferResult(destination='{self.destination}', amount={self.amount} TON, tx='{self.transaction_id}')"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UsdtTransferResult:
|
||||||
|
"""Result of a direct USDT transfer via :meth:`FragmentClient.send_usdt`."""
|
||||||
|
|
||||||
|
transaction_id: str
|
||||||
|
destination: str
|
||||||
|
amount: int
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"UsdtTransferResult(destination='{self.destination}', amount={self.amount} USDT, tx='{self.transaction_id}')"
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AdsRechargeResult",
|
"AdsRechargeResult",
|
||||||
"AdsTopupResult",
|
"AdsTopupResult",
|
||||||
@@ -217,6 +241,8 @@ __all__ = [
|
|||||||
"StarsGiveawayResult",
|
"StarsGiveawayResult",
|
||||||
"StarsResult",
|
"StarsResult",
|
||||||
"TerminateSessionsResult",
|
"TerminateSessionsResult",
|
||||||
|
"TonTransferResult",
|
||||||
|
"UsdtTransferResult",
|
||||||
"UsernamesResult",
|
"UsernamesResult",
|
||||||
"WalletInfo",
|
"WalletInfo",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
from pyfragment.utils.cookies import CookieResult, get_cookies_from_browser
|
from pyfragment.utils.api import (
|
||||||
from pyfragment.utils.decoder import clean_decode
|
|
||||||
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,
|
execute_transaction_request,
|
||||||
fragment_request,
|
fragment_request,
|
||||||
get_fragment_hash,
|
get_fragment_hash,
|
||||||
make_headers,
|
|
||||||
parse_json_response,
|
parse_json_response,
|
||||||
)
|
)
|
||||||
from pyfragment.utils.wallet import get_account_info, process_transaction
|
from pyfragment.utils.cookies import CookieResult, get_cookies_from_browser
|
||||||
|
from pyfragment.utils.parser import parse_auction_rows, parse_gift_items, parse_login_code, parse_required_payment_amount
|
||||||
|
from pyfragment.utils.wallet import clean_decode, get_account_info, process_transaction, send_ton_transfer, send_usdt_transfer
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"clean_decode",
|
"clean_decode",
|
||||||
@@ -22,7 +20,8 @@ __all__ = [
|
|||||||
"fragment_request",
|
"fragment_request",
|
||||||
"get_account_info",
|
"get_account_info",
|
||||||
"get_fragment_hash",
|
"get_fragment_hash",
|
||||||
"make_headers",
|
|
||||||
"parse_json_response",
|
"parse_json_response",
|
||||||
"process_transaction",
|
"process_transaction",
|
||||||
|
"send_ton_transfer",
|
||||||
|
"send_usdt_transfer",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -8,11 +8,7 @@ from typing import Any, cast
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from pyfragment.types import FragmentPageError, ParseError, VerificationError
|
from pyfragment.types import FragmentPageError, ParseError, VerificationError
|
||||||
from pyfragment.types.constants import BASE_HEADERS, DEFAULT_TIMEOUT, FRAGMENT_BASE_URL
|
from pyfragment.types.constants import DEFAULT_TIMEOUT, FRAGMENT_BASE_URL
|
||||||
|
|
||||||
|
|
||||||
def make_headers(page_url: str = FRAGMENT_BASE_URL) -> dict[str, str]:
|
|
||||||
return {**BASE_HEADERS, "referer": page_url, "x-aj-referer": page_url}
|
|
||||||
|
|
||||||
|
|
||||||
async def get_fragment_hash(
|
async def get_fragment_hash(
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
|
|
||||||
from ton_core import Cell
|
|
||||||
|
|
||||||
from pyfragment.types import ParseError
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
base64-decodes the payload, parses the cell, skips the 32-bit op-code
|
|
||||||
prefix, and reads the snake-encoded UTF-8 comment.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
payload: Base64url-encoded BOC string (padding is added automatically).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
s = payload.strip()
|
|
||||||
if not s:
|
|
||||||
return ""
|
|
||||||
s += "=" * (-len(s) % 4)
|
|
||||||
try:
|
|
||||||
# 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
|
|
||||||
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
|
|
||||||
@@ -1,241 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import base64
|
|
||||||
import random
|
|
||||||
import ssl
|
|
||||||
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,
|
|
||||||
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 _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
|
|
||||||
on-chain comment, and calls ``wallet.transfer``.
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
TransactionError: If the payload is malformed or the broadcast fails.
|
|
||||||
WalletError: If the wallet balance is too low or cannot be fetched.
|
|
||||||
"""
|
|
||||||
if "transaction" not in transaction_data or not transaction_data["transaction"].get("messages"):
|
|
||||||
raise TransactionError(TransactionError.INVALID_PAYLOAD)
|
|
||||||
|
|
||||||
message = transaction_data["transaction"]["messages"][0]
|
|
||||||
amount_ton = 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)
|
|
||||||
|
|
||||||
# Check balance covers selected payment flow requirements.
|
|
||||||
try:
|
|
||||||
await wallet.refresh()
|
|
||||||
balance_ton = wallet.balance / 1_000_000_000
|
|
||||||
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.TON_BALANCE_CHECK_FAILED.format(exc=exc)) from exc
|
|
||||||
|
|
||||||
try:
|
|
||||||
raw_payload = str(message.get("payload", ""))
|
|
||||||
payload = clean_decode(raw_payload)
|
|
||||||
|
|
||||||
for attempt in range(3):
|
|
||||||
try:
|
|
||||||
result = await wallet.transfer(
|
|
||||||
destination=message["address"],
|
|
||||||
amount=int(message["amount"]), # nanotons, not TON
|
|
||||||
body=payload,
|
|
||||||
)
|
|
||||||
return str(result.normalized_hash)
|
|
||||||
except ProviderResponseError as exc:
|
|
||||||
if exc.code == 429 and attempt == 0:
|
|
||||||
await asyncio.sleep(1 + random.uniform(0, 0.5))
|
|
||||||
continue
|
|
||||||
if exc.code == 406 and "seqno" in str(exc).lower():
|
|
||||||
# Previous tx seqno not yet confirmed — wallet will re-fetch seqno on retry
|
|
||||||
if attempt < 2:
|
|
||||||
await asyncio.sleep(2 + random.uniform(0, 1))
|
|
||||||
continue
|
|
||||||
raise TransactionError(TransactionError.DUPLICATE_SEQNO) from exc
|
|
||||||
raise
|
|
||||||
except (WalletError, TransactionError):
|
|
||||||
raise
|
|
||||||
except Exception as exc:
|
|
||||||
cause: BaseException | None = exc
|
|
||||||
while cause is not None:
|
|
||||||
if isinstance(cause, ssl.SSLError):
|
|
||||||
raise TransactionError(TransactionError.BROADCAST_FAILED_SSL.format(exc=exc)) from exc
|
|
||||||
cause = cause.__cause__ or cause.__context__
|
|
||||||
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc=exc)) from exc
|
|
||||||
|
|
||||||
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc="transfer loop exited without result"))
|
|
||||||
|
|
||||||
|
|
||||||
async def get_account_info(client: FragmentClient) -> dict[str, Any]:
|
|
||||||
"""Fetch wallet address, public key, and state-init for the Fragment API.
|
|
||||||
|
|
||||||
Fragment requires account info to build each transaction payload. The
|
|
||||||
returned dict is JSON-serialised and passed as the ``account`` field in
|
|
||||||
``getBuy*Link`` / ``get*Link`` requests.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
client: Authenticated :class:`FragmentClient` instance.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with ``address``, ``publicKey``, ``chain``, ``walletStateInit``.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
WalletError: If account info cannot be retrieved.
|
|
||||||
"""
|
|
||||||
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
|
|
||||||
try:
|
|
||||||
wallet_cls = WALLET_CLASSES[client.wallet_version]
|
|
||||||
wallet, pub_key, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
|
|
||||||
boc = wallet.state_init.serialize().to_boc()
|
|
||||||
return {
|
|
||||||
"address": wallet.address.to_str(False, False),
|
|
||||||
"publicKey": pub_key.as_hex,
|
|
||||||
"chain": "-239",
|
|
||||||
"walletStateInit": base64.b64encode(boc).decode(),
|
|
||||||
}
|
|
||||||
except Exception as exc:
|
|
||||||
raise WalletError(WalletError.ACCOUNT_INFO_FAILED.format(exc=exc)) from exc
|
|
||||||
|
|
||||||
|
|
||||||
async def get_wallet_info(client: FragmentClient) -> WalletInfo:
|
|
||||||
"""Return the address, state and balance of the TON wallet.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
client: Authenticated :class:`FragmentClient` instance.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
:class:`WalletInfo` with ``address``, ``state``, ``balance`` in TON,
|
|
||||||
and ``usdt_balance`` in USDT.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
WalletError: If the wallet state cannot be fetched.
|
|
||||||
"""
|
|
||||||
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
|
|
||||||
try:
|
|
||||||
wallet_cls = WALLET_CLASSES[client.wallet_version]
|
|
||||||
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
|
|
||||||
await wallet.refresh()
|
|
||||||
wallet_address = wallet.address.to_str(False, False)
|
|
||||||
usdt_balance = await _get_usdt_balance(ton, wallet_address)
|
|
||||||
return WalletInfo(
|
|
||||||
address=wallet.address.to_str(is_user_friendly=True, is_bounceable=False),
|
|
||||||
state=wallet.state.value,
|
|
||||||
ton_balance=round(wallet.balance / 1_000_000_000, 4),
|
|
||||||
usdt_balance=round(usdt_balance, 4),
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
raise WalletError(WalletError.WALLET_INFO_FAILED.format(exc=exc)) from exc
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from pyfragment.utils.wallet.balance import get_usdt_balance
|
||||||
|
from pyfragment.utils.wallet.info import get_account_info, get_wallet_info
|
||||||
|
from pyfragment.utils.wallet.transaction import clean_decode, process_transaction
|
||||||
|
from pyfragment.utils.wallet.transfer import send_ton_transfer, send_usdt_transfer
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"get_account_info",
|
||||||
|
"get_usdt_balance",
|
||||||
|
"get_wallet_info",
|
||||||
|
"process_transaction",
|
||||||
|
"clean_decode",
|
||||||
|
"send_ton_transfer",
|
||||||
|
"send_usdt_transfer",
|
||||||
|
]
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from tonutils.contracts.jetton import get_wallet_address_get_method, get_wallet_data_get_method
|
||||||
|
from tonutils.exceptions import ProviderResponseError
|
||||||
|
|
||||||
|
from pyfragment.types import WalletError
|
||||||
|
from pyfragment.types.constants import MIN_TON_BALANCE, MIN_USDT_BALANCE, USDT_TON_MASTER_ADDRESS
|
||||||
|
|
||||||
|
|
||||||
|
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))
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from ton_core import NetworkGlobalID
|
||||||
|
from tonutils.clients import TonapiClient
|
||||||
|
|
||||||
|
from pyfragment.types import WalletError, WalletInfo
|
||||||
|
from pyfragment.types.constants import WALLET_CLASSES
|
||||||
|
from pyfragment.utils.wallet.balance import get_usdt_balance
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pyfragment.client import FragmentClient
|
||||||
|
|
||||||
|
|
||||||
|
async def get_account_info(client: FragmentClient) -> dict[str, Any]:
|
||||||
|
"""Fetch wallet address, public key, and state-init for the Fragment API.
|
||||||
|
|
||||||
|
Fragment requires account info to build each transaction payload. The
|
||||||
|
returned dict is JSON-serialised and passed as the ``account`` field in
|
||||||
|
``getBuy*Link`` / ``get*Link`` requests.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Authenticated :class:`FragmentClient` instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with ``address``, ``publicKey``, ``chain``, ``walletStateInit``.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
WalletError: If account info cannot be retrieved.
|
||||||
|
"""
|
||||||
|
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
|
||||||
|
try:
|
||||||
|
wallet_cls = WALLET_CLASSES[client.wallet_version]
|
||||||
|
wallet, pub_key, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
|
||||||
|
boc = wallet.state_init.serialize().to_boc()
|
||||||
|
return {
|
||||||
|
"address": wallet.address.to_str(False, False),
|
||||||
|
"publicKey": pub_key.as_hex,
|
||||||
|
"chain": "-239",
|
||||||
|
"walletStateInit": base64.b64encode(boc).decode(),
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
raise WalletError(WalletError.ACCOUNT_INFO_FAILED.format(exc=exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
async def get_wallet_info(client: FragmentClient) -> WalletInfo:
|
||||||
|
"""Return the address, state and balance of the TON wallet.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Authenticated :class:`FragmentClient` instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`WalletInfo` with ``address``, ``state``, ``balance`` in TON,
|
||||||
|
and ``usdt_balance`` in USDT.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
WalletError: If the wallet state cannot be fetched.
|
||||||
|
"""
|
||||||
|
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
|
||||||
|
try:
|
||||||
|
wallet_cls = WALLET_CLASSES[client.wallet_version]
|
||||||
|
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
|
||||||
|
await wallet.refresh()
|
||||||
|
wallet_address = wallet.address.to_str(False, False)
|
||||||
|
usdt_balance = await get_usdt_balance(ton, wallet_address)
|
||||||
|
return WalletInfo(
|
||||||
|
address=wallet.address.to_str(is_user_friendly=True, is_bounceable=False),
|
||||||
|
state=wallet.state.value,
|
||||||
|
ton_balance=round(wallet.balance / 1_000_000_000, 4),
|
||||||
|
usdt_balance=round(usdt_balance, 4),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
raise WalletError(WalletError.WALLET_INFO_FAILED.format(exc=exc)) from exc
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import random
|
||||||
|
import ssl
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from ton_core import Cell, NetworkGlobalID
|
||||||
|
from tonutils.clients import TonapiClient
|
||||||
|
from tonutils.exceptions import ProviderResponseError
|
||||||
|
|
||||||
|
from pyfragment.types import ParseError, TransactionError, WalletError
|
||||||
|
from pyfragment.types.constants import WALLET_CLASSES, PaymentMethod
|
||||||
|
from pyfragment.utils.wallet.balance import check_ton_payment_balance, check_usdt_payment_balance
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pyfragment.client import FragmentClient
|
||||||
|
|
||||||
|
|
||||||
|
def clean_decode(payload: str) -> str | Cell:
|
||||||
|
"""Decode a base64-encoded BOC payload to a plain-text comment string."""
|
||||||
|
s = payload.strip()
|
||||||
|
if not s:
|
||||||
|
return ""
|
||||||
|
s += "=" * (-len(s) % 4)
|
||||||
|
try:
|
||||||
|
boc = base64.b64decode(s, altchars=b"-_", validate=True)
|
||||||
|
cell = Cell.one_from_boc(boc)
|
||||||
|
sl = cell.begin_parse()
|
||||||
|
op = sl.load_uint(32)
|
||||||
|
if op != 0:
|
||||||
|
# Non-zero op code means this is a structured message (e.g. jetton transfer),
|
||||||
|
# not a plain text comment — return the full cell as-is.
|
||||||
|
return cell
|
||||||
|
try:
|
||||||
|
return sl.load_snake_string().strip()
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
return cell
|
||||||
|
except Exception as exc:
|
||||||
|
raise ParseError(ParseError.UNPARSEABLE.format(context="payload decode", exc=exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
on-chain comment, and calls ``wallet.transfer``.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TransactionError: If the payload is malformed or the broadcast fails.
|
||||||
|
WalletError: If the wallet balance is too low or cannot be fetched.
|
||||||
|
"""
|
||||||
|
if "transaction" not in transaction_data or not transaction_data["transaction"].get("messages"):
|
||||||
|
raise TransactionError(TransactionError.INVALID_PAYLOAD)
|
||||||
|
|
||||||
|
message = transaction_data["transaction"]["messages"][0]
|
||||||
|
amount_ton = 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)
|
||||||
|
|
||||||
|
# Check balance covers selected payment flow requirements.
|
||||||
|
try:
|
||||||
|
await wallet.refresh()
|
||||||
|
balance_ton = wallet.balance / 1_000_000_000
|
||||||
|
if payment_method == "ton":
|
||||||
|
wallet.address.to_str(False, False)
|
||||||
|
await check_ton_payment_balance(balance_ton, amount_ton, required_payment_amount)
|
||||||
|
else:
|
||||||
|
# USDT is withdrawn from the Fragment-linked wallet (transaction["from"]),
|
||||||
|
# not from the signing seed wallet. Seed wallet only pays TON gas.
|
||||||
|
fragment_wallet_address = transaction_data["transaction"].get("from", "")
|
||||||
|
await check_usdt_payment_balance(balance_ton, required_payment_amount, ton, fragment_wallet_address)
|
||||||
|
except WalletError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise WalletError(WalletError.TON_BALANCE_CHECK_FAILED.format(exc=exc)) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw_payload = str(message.get("payload", ""))
|
||||||
|
payload = clean_decode(raw_payload)
|
||||||
|
|
||||||
|
for attempt in range(3):
|
||||||
|
try:
|
||||||
|
result = await wallet.transfer(
|
||||||
|
destination=message["address"],
|
||||||
|
amount=int(message["amount"]), # nanotons, not TON
|
||||||
|
body=payload,
|
||||||
|
)
|
||||||
|
return str(result.normalized_hash)
|
||||||
|
except ProviderResponseError as exc:
|
||||||
|
if exc.code == 429 and attempt == 0:
|
||||||
|
await asyncio.sleep(1 + random.uniform(0, 0.5))
|
||||||
|
continue
|
||||||
|
if exc.code == 406 and "seqno" in str(exc).lower():
|
||||||
|
# Previous tx seqno not yet confirmed — wallet will re-fetch seqno on retry
|
||||||
|
if attempt < 2:
|
||||||
|
await asyncio.sleep(2 + random.uniform(0, 1))
|
||||||
|
continue
|
||||||
|
raise TransactionError(TransactionError.DUPLICATE_SEQNO) from exc
|
||||||
|
raise
|
||||||
|
except (WalletError, TransactionError):
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
cause: BaseException | None = exc
|
||||||
|
while cause is not None:
|
||||||
|
if isinstance(cause, ssl.SSLError):
|
||||||
|
raise TransactionError(TransactionError.BROADCAST_FAILED_SSL.format(exc=exc)) from exc
|
||||||
|
cause = cause.__cause__ or cause.__context__
|
||||||
|
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc=exc)) from exc
|
||||||
|
|
||||||
|
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc="transfer loop exited without result"))
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from ton_core import Address, NetworkGlobalID
|
||||||
|
from tonutils.clients import ToncenterClient
|
||||||
|
from tonutils.contracts import JettonTransferBuilder, TONTransferBuilder
|
||||||
|
|
||||||
|
from pyfragment.types import TransactionError, WalletError
|
||||||
|
from pyfragment.types.constants import USDT_TON_MASTER_ADDRESS, WALLET_CLASSES
|
||||||
|
from pyfragment.types.results import TonTransferResult, UsdtTransferResult
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pyfragment.client import FragmentClient
|
||||||
|
|
||||||
|
|
||||||
|
async def send_ton_transfer(
|
||||||
|
client: FragmentClient,
|
||||||
|
destination: str,
|
||||||
|
amount: int,
|
||||||
|
body: str | None = None,
|
||||||
|
) -> TonTransferResult:
|
||||||
|
"""Send a direct TON transfer on-chain using ToncenterClient.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Authenticated :class:`FragmentClient` instance (seed and wallet_version used).
|
||||||
|
destination: Recipient TON address (any format, e.g. ``"UQ..."``).
|
||||||
|
amount: Amount in nanotons (1 TON = 1 000 000 000 nanotons).
|
||||||
|
body: Optional on-chain comment attached to the transfer.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`TonTransferResult` with ``transaction_id``, ``destination``, and ``amount``.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TransactionError: If the transaction fails to broadcast.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with ToncenterClient(network=NetworkGlobalID.MAINNET) as ton:
|
||||||
|
wallet_cls = WALLET_CLASSES[client.wallet_version]
|
||||||
|
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
|
||||||
|
result = await wallet.transfer_message(
|
||||||
|
TONTransferBuilder(
|
||||||
|
destination=Address(destination),
|
||||||
|
amount=amount,
|
||||||
|
body=body,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return TonTransferResult(
|
||||||
|
transaction_id=str(result.normalized_hash),
|
||||||
|
destination=destination,
|
||||||
|
amount=amount,
|
||||||
|
)
|
||||||
|
except (TransactionError, WalletError):
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc=exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
async def send_usdt_transfer(
|
||||||
|
client: FragmentClient,
|
||||||
|
destination: str,
|
||||||
|
usdt_amount: int,
|
||||||
|
forward_payload: str | None = None,
|
||||||
|
ton_for_gas: int = 50_000_000,
|
||||||
|
) -> UsdtTransferResult:
|
||||||
|
"""Send a direct USDT (TON jetton) transfer on-chain using ToncenterClient.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Authenticated :class:`FragmentClient` instance (seed and wallet_version used).
|
||||||
|
destination: Recipient TON address (any format, e.g. ``"UQ..."``).
|
||||||
|
usdt_amount: Amount in USDT base units (6 decimals; 1 USDT = 1 000 000).
|
||||||
|
forward_payload: Optional comment forwarded to the recipient with the transfer notification.
|
||||||
|
ton_for_gas: TON attached for gas in nanotons. Defaults to ``50_000_000`` (0.05 TON).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`UsdtTransferResult` with ``transaction_id``, ``destination``, and ``amount``.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TransactionError: If the transaction fails to broadcast.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with ToncenterClient(network=NetworkGlobalID.MAINNET) as ton:
|
||||||
|
wallet_cls = WALLET_CLASSES[client.wallet_version]
|
||||||
|
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
|
||||||
|
result = await wallet.transfer_message(
|
||||||
|
JettonTransferBuilder(
|
||||||
|
destination=Address(destination),
|
||||||
|
jetton_amount=usdt_amount,
|
||||||
|
jetton_master_address=Address(USDT_TON_MASTER_ADDRESS),
|
||||||
|
forward_payload=forward_payload,
|
||||||
|
forward_amount=1,
|
||||||
|
amount=ton_for_gas,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return UsdtTransferResult(
|
||||||
|
transaction_id=str(result.normalized_hash),
|
||||||
|
destination=destination,
|
||||||
|
amount=usdt_amount,
|
||||||
|
)
|
||||||
|
except (TransactionError, WalletError):
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc=exc)) from exc
|
||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "pyfragment"
|
name = "pyfragment"
|
||||||
version = "2026.2.2"
|
version = "2026.2.3"
|
||||||
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 TON and Ads balances, run giveaways, manage anonymous numbers, and search Fragment listings."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = { text = "MIT" }
|
license = { text = "MIT" }
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import pytest
|
|||||||
from ton_core import Cell
|
from ton_core import Cell
|
||||||
|
|
||||||
from pyfragment.types import ParseError
|
from pyfragment.types import ParseError
|
||||||
from pyfragment.utils.decoder import clean_decode
|
from pyfragment.utils.wallet.transaction import clean_decode
|
||||||
|
|
||||||
PAYLOAD_CASES = [
|
PAYLOAD_CASES = [
|
||||||
pytest.param(
|
pytest.param(
|
||||||
@@ -86,7 +86,7 @@ def test_decode_payload_accepts_base64url_alphabet() -> None:
|
|||||||
raw = b"\xfb\xef\xff\x00"
|
raw = b"\xfb\xef\xff\x00"
|
||||||
payload = base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
payload = base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
||||||
|
|
||||||
with patch("pyfragment.utils.decoder.Cell.one_from_boc", return_value=_FakeCell()) as mocked:
|
with patch("pyfragment.utils.wallet.transaction.Cell.one_from_boc", return_value=_FakeCell()) as mocked:
|
||||||
result = clean_decode(payload)
|
result = clean_decode(payload)
|
||||||
|
|
||||||
mocked.assert_called_once_with(raw)
|
mocked.assert_called_once_with(raw)
|
||||||
@@ -106,7 +106,7 @@ def test_clean_decode_returns_text_comment_when_utf8() -> None:
|
|||||||
return _FakeSlice()
|
return _FakeSlice()
|
||||||
|
|
||||||
payload = base64.urlsafe_b64encode(b"\x00\x01").decode().rstrip("=")
|
payload = base64.urlsafe_b64encode(b"\x00\x01").decode().rstrip("=")
|
||||||
with patch("pyfragment.utils.decoder.Cell.one_from_boc", return_value=_FakeCell()):
|
with patch("pyfragment.utils.wallet.transaction.Cell.one_from_boc", return_value=_FakeCell()):
|
||||||
parsed = clean_decode(payload)
|
parsed = clean_decode(payload)
|
||||||
|
|
||||||
assert parsed == "Telegram Premium Ref#abc"
|
assert parsed == "Telegram Premium Ref#abc"
|
||||||
@@ -126,7 +126,7 @@ def test_clean_decode_returns_cell_for_binary_payload() -> None:
|
|||||||
|
|
||||||
payload = base64.urlsafe_b64encode(b"\x00\x01").decode().rstrip("=")
|
payload = base64.urlsafe_b64encode(b"\x00\x01").decode().rstrip("=")
|
||||||
fake_cell: object = _FakeCell()
|
fake_cell: object = _FakeCell()
|
||||||
with patch("pyfragment.utils.decoder.Cell.one_from_boc", return_value=fake_cell):
|
with patch("pyfragment.utils.wallet.transaction.Cell.one_from_boc", return_value=fake_cell):
|
||||||
parsed = clean_decode(payload)
|
parsed = clean_decode(payload)
|
||||||
|
|
||||||
assert parsed is fake_cell
|
assert parsed is fake_cell
|
||||||
|
|||||||
@@ -48,8 +48,8 @@ def _make_wallet(balance_nanotons: int) -> MagicMock:
|
|||||||
@contextmanager
|
@contextmanager
|
||||||
def _patch_wallet(wallet: MagicMock) -> Generator[None, None, None]:
|
def _patch_wallet(wallet: MagicMock) -> Generator[None, None, None]:
|
||||||
with (
|
with (
|
||||||
patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi,
|
patch("pyfragment.utils.wallet.transaction.TonapiClient") as mock_tonapi,
|
||||||
patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes,
|
patch("pyfragment.utils.wallet.transaction.WALLET_CLASSES") as mock_classes,
|
||||||
):
|
):
|
||||||
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
|
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
|
||||||
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
|
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||||
@@ -63,7 +63,7 @@ def _patch_wallet(wallet: MagicMock) -> Generator[None, None, None]:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_sufficient_balance_broadcasts() -> None:
|
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 TON, above threshold
|
||||||
with _patch_wallet(wallet), patch("pyfragment.utils.wallet.clean_decode", return_value="50 Telegram Stars"):
|
with _patch_wallet(wallet), patch("pyfragment.utils.wallet.transaction.clean_decode", return_value="50 Telegram Stars"):
|
||||||
result = await process_transaction(_make_client(), TRANSACTION_DATA)
|
result = await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||||
assert result == "abc123"
|
assert result == "abc123"
|
||||||
wallet.transfer.assert_called_once()
|
wallet.transfer.assert_called_once()
|
||||||
@@ -81,7 +81,7 @@ async def test_insufficient_balance_raises() -> None:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_exact_minimum_balance_broadcasts() -> None:
|
async def test_exact_minimum_balance_broadcasts() -> None:
|
||||||
wallet = _make_wallet(balance_nanotons=500_000_000) # exactly transaction amount threshold
|
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"):
|
with _patch_wallet(wallet), patch("pyfragment.utils.wallet.transaction.clean_decode", return_value="50 Telegram Stars"):
|
||||||
result = await process_transaction(_make_client(), TRANSACTION_DATA)
|
result = await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||||
assert result == "abc123"
|
assert result == "abc123"
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ async def test_balance_check_failed_raises_wallet_error() -> None:
|
|||||||
async def test_rate_limit_retries_and_succeeds() -> None:
|
async def test_rate_limit_retries_and_succeeds() -> None:
|
||||||
wallet = _make_wallet(balance_nanotons=1_000_000_000)
|
wallet = _make_wallet(balance_nanotons=1_000_000_000)
|
||||||
wallet.transfer = AsyncMock(side_effect=[_provider_error(429, "rate limited"), MagicMock(normalized_hash="abc123")])
|
wallet.transfer = AsyncMock(side_effect=[_provider_error(429, "rate limited"), MagicMock(normalized_hash="abc123")])
|
||||||
with _patch_wallet(wallet), patch("pyfragment.utils.wallet.clean_decode", return_value=""):
|
with _patch_wallet(wallet), patch("pyfragment.utils.wallet.transaction.clean_decode", return_value=""):
|
||||||
result = await process_transaction(_make_client(), TRANSACTION_DATA)
|
result = await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||||
assert result == "abc123"
|
assert result == "abc123"
|
||||||
assert wallet.transfer.call_count == 2
|
assert wallet.transfer.call_count == 2
|
||||||
@@ -134,7 +134,7 @@ async def test_duplicate_seqno_raises_after_retries() -> None:
|
|||||||
wallet = _make_wallet(balance_nanotons=1_000_000_000)
|
wallet = _make_wallet(balance_nanotons=1_000_000_000)
|
||||||
err = _provider_error(406, "Duplicate msg_seqno")
|
err = _provider_error(406, "Duplicate msg_seqno")
|
||||||
wallet.transfer = AsyncMock(side_effect=[err, err, err])
|
wallet.transfer = AsyncMock(side_effect=[err, err, err])
|
||||||
with _patch_wallet(wallet), patch("pyfragment.utils.wallet.clean_decode", return_value=""):
|
with _patch_wallet(wallet), patch("pyfragment.utils.wallet.transaction.clean_decode", return_value=""):
|
||||||
with pytest.raises(TransactionError, match="seqno"):
|
with pytest.raises(TransactionError, match="seqno"):
|
||||||
await process_transaction(_make_client(), TRANSACTION_DATA)
|
await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||||
assert wallet.transfer.call_count == 3
|
assert wallet.transfer.call_count == 3
|
||||||
@@ -143,7 +143,7 @@ async def test_duplicate_seqno_raises_after_retries() -> None:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_usdt_payment_requires_min_ton_gas_reserve() -> None:
|
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
|
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 _patch_wallet(wallet), patch("pyfragment.utils.wallet.balance.get_usdt_balance", AsyncMock(return_value=100.0)):
|
||||||
with pytest.raises(WalletError, match="Insufficient TON balance"):
|
with pytest.raises(WalletError, match="Insufficient TON balance"):
|
||||||
await process_transaction(_make_client(), TRANSACTION_DATA, payment_method="usdt_ton")
|
await process_transaction(_make_client(), TRANSACTION_DATA, payment_method="usdt_ton")
|
||||||
|
|
||||||
@@ -166,8 +166,8 @@ async def test_usdt_payment_checks_usdt_balance() -> None:
|
|||||||
|
|
||||||
with (
|
with (
|
||||||
_patch_wallet(wallet),
|
_patch_wallet(wallet),
|
||||||
patch("pyfragment.utils.wallet.clean_decode", return_value=""),
|
patch("pyfragment.utils.wallet.transaction.clean_decode", return_value=""),
|
||||||
patch("pyfragment.utils.wallet._get_usdt_balance", AsyncMock(return_value=5.0)),
|
patch("pyfragment.utils.wallet.balance.get_usdt_balance", AsyncMock(return_value=5.0)),
|
||||||
):
|
):
|
||||||
with pytest.raises(WalletError, match="Insufficient USDT balance"):
|
with pytest.raises(WalletError, match="Insufficient USDT balance"):
|
||||||
await process_transaction(
|
await process_transaction(
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ async def test_get_wallet_returns_wallet_info(client: FragmentClient) -> None:
|
|||||||
mock_wallet.address.to_str.return_value = FAKE_ADDRESS
|
mock_wallet.address.to_str.return_value = FAKE_ADDRESS
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi,
|
patch("pyfragment.utils.wallet.info.TonapiClient") as mock_tonapi,
|
||||||
patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes,
|
patch("pyfragment.utils.wallet.info.WALLET_CLASSES") as mock_classes,
|
||||||
patch("pyfragment.utils.wallet._get_usdt_balance", AsyncMock(return_value=12.3456)),
|
patch("pyfragment.utils.wallet.info.get_usdt_balance", AsyncMock(return_value=12.3456)),
|
||||||
):
|
):
|
||||||
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
|
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
|
||||||
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
|
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||||
@@ -45,9 +45,9 @@ async def test_get_wallet_balance_is_zero(client: FragmentClient) -> None:
|
|||||||
mock_wallet.address.to_str.return_value = FAKE_ADDRESS
|
mock_wallet.address.to_str.return_value = FAKE_ADDRESS
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi,
|
patch("pyfragment.utils.wallet.info.TonapiClient") as mock_tonapi,
|
||||||
patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes,
|
patch("pyfragment.utils.wallet.info.WALLET_CLASSES") as mock_classes,
|
||||||
patch("pyfragment.utils.wallet._get_usdt_balance", AsyncMock(return_value=0.0)),
|
patch("pyfragment.utils.wallet.info.get_usdt_balance", AsyncMock(return_value=0.0)),
|
||||||
):
|
):
|
||||||
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
|
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
|
||||||
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
|
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import pytest
|
|||||||
|
|
||||||
from pyfragment import FragmentClient
|
from pyfragment import FragmentClient
|
||||||
from pyfragment.types import FragmentPageError
|
from pyfragment.types import FragmentPageError
|
||||||
from pyfragment.utils.http import fragment_request
|
from pyfragment.utils.api import fragment_request
|
||||||
from tests.shared import FAKE_HASH, FAKE_RESPONSE
|
from tests.shared import FAKE_HASH, FAKE_RESPONSE
|
||||||
|
|
||||||
# client.call() mocked tests
|
# client.call() mocked tests
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Unit tests for init payment amount parsing."""
|
"""Unit tests for init payment amount parsing."""
|
||||||
|
|
||||||
from pyfragment.utils.html import parse_required_payment_amount
|
from pyfragment.utils.parser import parse_required_payment_amount
|
||||||
|
|
||||||
|
|
||||||
def test_parse_required_payment_amount_ton_uses_amount() -> None:
|
def test_parse_required_payment_amount_ton_uses_amount() -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user