refactor: restructure as installable PyPI package

- Rename app/ → fragmentapi/ for proper package naming
- Add FragmentClient class with gift_premium, gift_stars, topup_ton methods
- Restructure core/ → types/ (exceptions, results, constants)
- Merge utils/hash.py into utils/client.py
- Replace _version.py with importlib.metadata
- Add input validation with min/max bounds
- Add unit tests: decode, client init/cookies
- Rename 002_test_hash → 003_test_hash
- Clean up pyproject.toml: pin deps, production classifiers
This commit is contained in:
bohd4nx
2026-03-15 21:03:03 +02:00
parent 4e017cb7a1
commit d2d046a2b9
38 changed files with 1051 additions and 959 deletions
+16
View File
@@ -0,0 +1,16 @@
from fragmentapi.utils.client import (
execute_transaction_request,
get_fragment_hash,
parse_json_response,
)
from fragmentapi.utils.decoder import clean_decode
from fragmentapi.utils.wallet import get_account_info, process_transaction
__all__ = [
"clean_decode",
"execute_transaction_request",
"get_account_info",
"get_fragment_hash",
"parse_json_response",
"process_transaction",
]
+107
View File
@@ -0,0 +1,107 @@
import re
from typing import Any
import httpx
from fragmentapi.types import HashFetchError, RequestError, VerificationError
async def get_fragment_hash(
cookies: dict[str, Any],
headers: dict[str, str],
page_url: str,
) -> str:
"""Fetch the API hash from a Fragment page.
Fragment embeds a short-lived hash in each page's HTML that must be
included in every subsequent API request. This function loads the page
as a real browser navigation (not XHR) so Fragment returns full HTML.
Args:
cookies: Active Fragment session cookies.
headers: Base headers for the relevant Fragment page.
page_url: URL of the Fragment page to fetch the hash from.
Returns:
Lowercase hex hash string.
Raises:
HashFetchError: If the page returns a non-200 status or the hash
is not found in the response HTML.
"""
page_headers = {
k: v
for k, v in headers.items()
if k not in ("accept", "accept-encoding", "content-type", "x-requested-with", "x-aj-referer")
}
page_headers.update(
{
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"referer": "https://fragment.com/",
"sec-fetch-dest": "document",
"sec-fetch-mode": "navigate",
"upgrade-insecure-requests": "1",
}
)
async with httpx.AsyncClient(cookies=cookies) as session:
response = await session.get(page_url, headers=page_headers)
if response.status_code != 200:
raise HashFetchError(HashFetchError.BAD_STATUS.format(status=response.status_code, url=page_url))
match = re.search(r"(?:https://fragment\.com)?/api\?hash=([a-f0-9]+)", response.text)
if not match:
raise HashFetchError(HashFetchError.NOT_FOUND.format(url=page_url))
return match.group(1)
def parse_json_response(response: httpx.Response, context: str) -> dict[str, Any]:
"""Parse a Fragment API JSON response.
Args:
response: The HTTP response object.
context: Human-readable name of the API method, used in error messages.
Returns:
Parsed response as a dict.
Raises:
RequestError: If the response body cannot be decoded as JSON.
"""
try:
return response.json()
except Exception as exc:
raise RequestError(RequestError.UNPARSEABLE.format(context=context, exc=exc)) from exc
async def execute_transaction_request(
session: httpx.AsyncClient,
headers: dict,
tx_data: dict[str, Any],
fragment_hash: str,
) -> dict[str, Any]:
"""Post a transaction request to the Fragment API.
Args:
session: Active httpx session with Fragment cookies.
headers: Page-specific HTTP headers.
tx_data: Form data payload for the API method.
fragment_hash: Short-lived hash from the Fragment page.
Returns:
Parsed API response dict containing transaction data.
Raises:
VerificationError: If Fragment requires KYC verification.
RequestError: If the response cannot be parsed.
"""
url = f"https://fragment.com/api?hash={fragment_hash}"
resp = await session.post(url, headers=headers, data=tx_data)
transaction = parse_json_response(resp, tx_data.get("method", "transaction"))
if transaction.get("need_verify"):
raise VerificationError(VerificationError.KYC_REQUIRED)
return transaction
+20
View File
@@ -0,0 +1,20 @@
import base64
from pytoniq_core import Cell
from fragmentapi.types import RequestError
def clean_decode(payload: str) -> str:
s = payload.strip()
if not s:
return ""
s += "=" * (-len(s) % 4)
try:
boc = base64.b64decode(s)
cell = Cell.one_from_boc(boc)
sl = cell.begin_parse()
sl.load_uint(32) # op code — always 0 for text comment
return sl.load_snake_string().strip()
except Exception as exc:
raise RequestError(RequestError.UNPARSEABLE.format(context="payload decode", exc=exc)) from exc
+69
View File
@@ -0,0 +1,69 @@
import base64
from typing import TYPE_CHECKING, Any
from tonutils.clients import TonapiClient
from tonutils.types import NetworkGlobalID
from fragmentapi.types import WALLET_CLASSES, TransactionError, WalletError
from fragmentapi.utils.decoder import clean_decode
if TYPE_CHECKING:
from fragmentapi.client import FragmentClient
def _init_ton_client(client: "FragmentClient") -> TonapiClient:
return TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key)
async def process_transaction(client: "FragmentClient", transaction_data: dict) -> str:
if "transaction" not in transaction_data or "messages" not in transaction_data["transaction"]:
raise TransactionError(TransactionError.INVALID_PAYLOAD)
# TODO: Investigate 406 'inbound external message rejected before smart-contract execution'.
# This happens when the previous transaction's seqno hasn't been confirmed on-chain yet,
# causing the wallet contract to reject the new message.
async with _init_ton_client(client) as ton:
wallet_cls = WALLET_CLASSES[client.wallet_version]
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
# Check balance before broadcasting
try:
await wallet.refresh()
balance_ton = wallet.balance / 1_000_000_000
if balance_ton < 0.056:
raise WalletError(WalletError.LOW_BALANCE.format(balance=balance_ton))
except WalletError:
raise
except Exception as exc:
raise WalletError(WalletError.BALANCE_CHECK_FAILED.format(exc=exc)) from exc
try:
message = transaction_data["transaction"]["messages"][0]
payload = clean_decode(message["payload"])
result = await wallet.transfer(
destination=message["address"],
amount=int(message["amount"]), # nanotons, not TON
body=payload,
)
return result.normalized_hash
except (WalletError, TransactionError):
raise
except Exception as exc:
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc=exc)) from exc
async def get_account_info(client: "FragmentClient") -> dict[str, Any]:
async with _init_ton_client(client) as ton:
try:
wallet_cls = WALLET_CLASSES[client.wallet_version]
wallet, pub_key, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
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