tonflow 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tonflow/__init__.py +63 -0
- tonflow/addresses.py +70 -0
- tonflow/cache.py +120 -0
- tonflow/client.py +328 -0
- tonflow/exceptions.py +26 -0
- tonflow/export.py +101 -0
- tonflow/jettons.py +126 -0
- tonflow/models.py +82 -0
- tonflow/stream.py +75 -0
- tonflow-0.1.0.dist-info/METADATA +310 -0
- tonflow-0.1.0.dist-info/RECORD +13 -0
- tonflow-0.1.0.dist-info/WHEEL +4 -0
- tonflow-0.1.0.dist-info/licenses/LICENSE +21 -0
tonflow/__init__.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""TON blockchain parsing and local indexing toolkit."""
|
|
2
|
+
|
|
3
|
+
from tonflow.addresses import (
|
|
4
|
+
is_raw_address,
|
|
5
|
+
is_user_friendly_address,
|
|
6
|
+
normalize_address,
|
|
7
|
+
validate_address,
|
|
8
|
+
)
|
|
9
|
+
from tonflow.cache import InMemoryCache, JSONCache, SQLiteCache
|
|
10
|
+
from tonflow.client import TonClient
|
|
11
|
+
from tonflow.export import (
|
|
12
|
+
jetton_transfers_to_csv,
|
|
13
|
+
jetton_transfers_to_json,
|
|
14
|
+
transactions_to_csv,
|
|
15
|
+
transactions_to_json,
|
|
16
|
+
)
|
|
17
|
+
from tonflow.jettons import (
|
|
18
|
+
decode_jetton_transfer,
|
|
19
|
+
extract_jetton_transfers,
|
|
20
|
+
is_jetton_burn,
|
|
21
|
+
is_jetton_transfer,
|
|
22
|
+
is_jetton_transfer_notification,
|
|
23
|
+
normalize_amount,
|
|
24
|
+
)
|
|
25
|
+
from tonflow.models import (
|
|
26
|
+
JettonTransfer,
|
|
27
|
+
Message,
|
|
28
|
+
MessageDirection,
|
|
29
|
+
RawPayload,
|
|
30
|
+
TonflowModel,
|
|
31
|
+
Transaction,
|
|
32
|
+
TransactionStatus,
|
|
33
|
+
)
|
|
34
|
+
from tonflow.stream import watch_address
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"InMemoryCache",
|
|
38
|
+
"JSONCache",
|
|
39
|
+
"JettonTransfer",
|
|
40
|
+
"Message",
|
|
41
|
+
"MessageDirection",
|
|
42
|
+
"RawPayload",
|
|
43
|
+
"TonClient",
|
|
44
|
+
"TonflowModel",
|
|
45
|
+
"Transaction",
|
|
46
|
+
"TransactionStatus",
|
|
47
|
+
"SQLiteCache",
|
|
48
|
+
"decode_jetton_transfer",
|
|
49
|
+
"extract_jetton_transfers",
|
|
50
|
+
"is_jetton_burn",
|
|
51
|
+
"is_jetton_transfer",
|
|
52
|
+
"is_jetton_transfer_notification",
|
|
53
|
+
"normalize_amount",
|
|
54
|
+
"watch_address",
|
|
55
|
+
"jetton_transfers_to_csv",
|
|
56
|
+
"jetton_transfers_to_json",
|
|
57
|
+
"transactions_to_csv",
|
|
58
|
+
"transactions_to_json",
|
|
59
|
+
"is_raw_address",
|
|
60
|
+
"is_user_friendly_address",
|
|
61
|
+
"normalize_address",
|
|
62
|
+
"validate_address",
|
|
63
|
+
]
|
tonflow/addresses.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Address helpers for TON account identifiers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
# User-friendly address: 48 base64url chars, first byte encodes workchain + flags.
|
|
9
|
+
# EQ = bounceable mainnet (workchain 0)
|
|
10
|
+
# UQ = non-bounceable mainnet (workchain 0)
|
|
11
|
+
# kQ = bounceable testnet (workchain 0)
|
|
12
|
+
# 0Q = non-bounceable testnet (workchain 0)
|
|
13
|
+
_USER_FRIENDLY_PREFIXES = ("EQ", "UQ", "kQ", "0Q")
|
|
14
|
+
_USER_FRIENDLY_LENGTH = 48
|
|
15
|
+
_USER_FRIENDLY_RE = re.compile(r"^[A-Za-z0-9_\-]{48}$")
|
|
16
|
+
|
|
17
|
+
# Raw address: "<workchain>:<64 hex chars>"
|
|
18
|
+
_RAW_RE = re.compile(r"^-?[0-9]+:[0-9a-fA-F]{64}$")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def normalize_address(address: str) -> str:
|
|
22
|
+
"""Return the trimmed TON address, raising ValueError if blank."""
|
|
23
|
+
normalized = address.strip()
|
|
24
|
+
if not normalized:
|
|
25
|
+
raise ValueError("TON address cannot be empty.")
|
|
26
|
+
return normalized
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def is_user_friendly_address(address: str) -> bool:
|
|
30
|
+
"""Return True if *address* looks like a valid TON user-friendly address.
|
|
31
|
+
|
|
32
|
+
Checks:
|
|
33
|
+
- length is exactly 48 characters
|
|
34
|
+
- characters are valid base64url (A-Z, a-z, 0-9, ``-``, ``_``)
|
|
35
|
+
- starts with a known workchain prefix (EQ, UQ, kQ, 0Q)
|
|
36
|
+
- base64-decoded payload is exactly 36 bytes (tag + workchain + hash + crc)
|
|
37
|
+
"""
|
|
38
|
+
addr = address.strip()
|
|
39
|
+
if len(addr) != _USER_FRIENDLY_LENGTH:
|
|
40
|
+
return False
|
|
41
|
+
if not addr.startswith(_USER_FRIENDLY_PREFIXES):
|
|
42
|
+
return False
|
|
43
|
+
if not _USER_FRIENDLY_RE.match(addr):
|
|
44
|
+
return False
|
|
45
|
+
try:
|
|
46
|
+
decoded = base64.urlsafe_b64decode(addr + "==")
|
|
47
|
+
except Exception:
|
|
48
|
+
return False
|
|
49
|
+
return len(decoded) == 36
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def is_raw_address(address: str) -> bool:
|
|
53
|
+
"""Return True if *address* is a TON raw address (``workchain:hex``)."""
|
|
54
|
+
return bool(_RAW_RE.match(address.strip()))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def validate_address(address: str) -> str:
|
|
58
|
+
"""Return the trimmed address if it is a recognized TON format.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
ValueError: if the address is neither user-friendly nor raw.
|
|
62
|
+
"""
|
|
63
|
+
addr = normalize_address(address)
|
|
64
|
+
if is_user_friendly_address(addr) or is_raw_address(addr):
|
|
65
|
+
return addr
|
|
66
|
+
raise ValueError(
|
|
67
|
+
f"'{addr}' is not a valid TON address. "
|
|
68
|
+
"Expected a 48-char user-friendly address (EQ/UQ/kQ/0Q...) "
|
|
69
|
+
"or a raw address (workchain:64hexchars)."
|
|
70
|
+
)
|
tonflow/cache.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Small cache primitives used by clients and streams."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sqlite3
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from time import monotonic, time
|
|
10
|
+
from typing import Protocol, TypeVar
|
|
11
|
+
|
|
12
|
+
from tonflow.models import RawPayload
|
|
13
|
+
|
|
14
|
+
T = TypeVar("T")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class JSONCache(Protocol):
|
|
18
|
+
"""Cache backend interface for JSON-compatible API responses."""
|
|
19
|
+
|
|
20
|
+
def get(self, key: str) -> RawPayload | None:
|
|
21
|
+
"""Return a cached JSON payload or None when it is missing/expired."""
|
|
22
|
+
|
|
23
|
+
def set(self, key: str, value: RawPayload, ttl_seconds: float | None = None) -> None:
|
|
24
|
+
"""Store a JSON payload with an optional TTL."""
|
|
25
|
+
|
|
26
|
+
def clear(self) -> None:
|
|
27
|
+
"""Remove all cached values."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(slots=True)
|
|
31
|
+
class _CacheEntry[T]:
|
|
32
|
+
value: T
|
|
33
|
+
expires_at: float | None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class InMemoryCache:
|
|
37
|
+
"""Simple TTL cache for tests, examples, and short-lived scripts."""
|
|
38
|
+
|
|
39
|
+
def __init__(self) -> None:
|
|
40
|
+
self._items: dict[str, _CacheEntry[object]] = {}
|
|
41
|
+
|
|
42
|
+
def get[T](self, key: str) -> T | None:
|
|
43
|
+
entry = self._items.get(key)
|
|
44
|
+
if entry is None:
|
|
45
|
+
return None
|
|
46
|
+
if entry.expires_at is not None and entry.expires_at <= monotonic():
|
|
47
|
+
self._items.pop(key, None)
|
|
48
|
+
return None
|
|
49
|
+
return entry.value # type: ignore[return-value]
|
|
50
|
+
|
|
51
|
+
def set(self, key: str, value: object, ttl_seconds: float | None = None) -> None:
|
|
52
|
+
expires_at = None if ttl_seconds is None else monotonic() + ttl_seconds
|
|
53
|
+
self._items[key] = _CacheEntry(value=value, expires_at=expires_at)
|
|
54
|
+
|
|
55
|
+
def clear(self) -> None:
|
|
56
|
+
self._items.clear()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class SQLiteCache:
|
|
60
|
+
"""SQLite-backed TTL cache for local scripts and small services."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, path: str | Path) -> None:
|
|
63
|
+
self.path = Path(path)
|
|
64
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
self._initialize()
|
|
66
|
+
|
|
67
|
+
def get(self, key: str) -> RawPayload | None:
|
|
68
|
+
now = time()
|
|
69
|
+
with self._connect() as connection:
|
|
70
|
+
row = connection.execute(
|
|
71
|
+
"SELECT value, expires_at FROM cache_entries WHERE key = ?",
|
|
72
|
+
(key,),
|
|
73
|
+
).fetchone()
|
|
74
|
+
if row is None:
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
value, expires_at = row
|
|
78
|
+
if expires_at is not None and expires_at <= now:
|
|
79
|
+
connection.execute("DELETE FROM cache_entries WHERE key = ?", (key,))
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
data = json.loads(value)
|
|
83
|
+
if not isinstance(data, dict):
|
|
84
|
+
msg = "Cached payload must be a JSON object."
|
|
85
|
+
raise ValueError(msg)
|
|
86
|
+
return data
|
|
87
|
+
|
|
88
|
+
def set(self, key: str, value: RawPayload, ttl_seconds: float | None = None) -> None:
|
|
89
|
+
expires_at = None if ttl_seconds is None else time() + ttl_seconds
|
|
90
|
+
encoded = json.dumps(value, sort_keys=True, separators=(",", ":"))
|
|
91
|
+
with self._connect() as connection:
|
|
92
|
+
connection.execute(
|
|
93
|
+
"""
|
|
94
|
+
INSERT INTO cache_entries (key, value, expires_at)
|
|
95
|
+
VALUES (?, ?, ?)
|
|
96
|
+
ON CONFLICT(key) DO UPDATE SET
|
|
97
|
+
value = excluded.value,
|
|
98
|
+
expires_at = excluded.expires_at
|
|
99
|
+
""",
|
|
100
|
+
(key, encoded, expires_at),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def clear(self) -> None:
|
|
104
|
+
with self._connect() as connection:
|
|
105
|
+
connection.execute("DELETE FROM cache_entries")
|
|
106
|
+
|
|
107
|
+
def _initialize(self) -> None:
|
|
108
|
+
with self._connect() as connection:
|
|
109
|
+
connection.execute(
|
|
110
|
+
"""
|
|
111
|
+
CREATE TABLE IF NOT EXISTS cache_entries (
|
|
112
|
+
key TEXT PRIMARY KEY,
|
|
113
|
+
value TEXT NOT NULL,
|
|
114
|
+
expires_at REAL
|
|
115
|
+
)
|
|
116
|
+
"""
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def _connect(self) -> sqlite3.Connection:
|
|
120
|
+
return sqlite3.connect(self.path)
|
tonflow/client.py
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""Client entry points for reading TON blockchain data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.parse import urlencode
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from tonflow.addresses import normalize_address
|
|
12
|
+
from tonflow.cache import JSONCache
|
|
13
|
+
from tonflow.exceptions import TonflowAPIError, TonflowDecodeError
|
|
14
|
+
from tonflow.jettons import decode_jetton_transfer
|
|
15
|
+
from tonflow.models import (
|
|
16
|
+
JettonTransfer,
|
|
17
|
+
Message,
|
|
18
|
+
MessageDirection,
|
|
19
|
+
RawPayload,
|
|
20
|
+
Transaction,
|
|
21
|
+
TransactionStatus,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(slots=True)
|
|
26
|
+
class TonClient:
|
|
27
|
+
"""High-level async TON API client."""
|
|
28
|
+
|
|
29
|
+
endpoint: str
|
|
30
|
+
api_key: str | None = None
|
|
31
|
+
timeout: float = 10.0
|
|
32
|
+
http_client: httpx.AsyncClient | None = None
|
|
33
|
+
cache: JSONCache | None = None
|
|
34
|
+
cache_ttl_seconds: float | None = 30.0
|
|
35
|
+
_owns_http_client: bool = field(default=False, init=False, repr=False)
|
|
36
|
+
|
|
37
|
+
async def __aenter__(self) -> TonClient:
|
|
38
|
+
return self
|
|
39
|
+
|
|
40
|
+
async def __aexit__(self, *_exc_info: object) -> None:
|
|
41
|
+
await self.aclose()
|
|
42
|
+
|
|
43
|
+
async def aclose(self) -> None:
|
|
44
|
+
"""Close the owned HTTP client, if tonflow created one."""
|
|
45
|
+
|
|
46
|
+
if self.http_client is not None and self._owns_http_client:
|
|
47
|
+
await self.http_client.aclose()
|
|
48
|
+
self.http_client = None
|
|
49
|
+
self._owns_http_client = False
|
|
50
|
+
|
|
51
|
+
async def get_transactions(
|
|
52
|
+
self,
|
|
53
|
+
address: str,
|
|
54
|
+
*,
|
|
55
|
+
limit: int = 20,
|
|
56
|
+
before_lt: int | None = None,
|
|
57
|
+
) -> list[Transaction]:
|
|
58
|
+
"""Fetch and normalize account transactions."""
|
|
59
|
+
|
|
60
|
+
normalized = normalize_address(address)
|
|
61
|
+
if limit <= 0:
|
|
62
|
+
msg = "limit must be greater than zero."
|
|
63
|
+
raise ValueError(msg)
|
|
64
|
+
if before_lt is not None and before_lt < 0:
|
|
65
|
+
msg = "before_lt must be greater than or equal to zero."
|
|
66
|
+
raise ValueError(msg)
|
|
67
|
+
|
|
68
|
+
payload = await self._request_json(
|
|
69
|
+
"GET",
|
|
70
|
+
f"/v2/blockchain/accounts/{normalized}/transactions",
|
|
71
|
+
params={"limit": limit, "before_lt": before_lt},
|
|
72
|
+
)
|
|
73
|
+
transactions = _extract_transactions(payload)
|
|
74
|
+
return [_parse_transaction(item, account=normalized) for item in transactions]
|
|
75
|
+
|
|
76
|
+
async def get_jetton_transfers(
|
|
77
|
+
self,
|
|
78
|
+
address: str,
|
|
79
|
+
*,
|
|
80
|
+
limit: int = 20,
|
|
81
|
+
before_lt: int | None = None,
|
|
82
|
+
decimals: int = 9,
|
|
83
|
+
jetton_minter: str | None = None,
|
|
84
|
+
symbol: str | None = None,
|
|
85
|
+
) -> list[JettonTransfer]:
|
|
86
|
+
"""Fetch transactions for *address* and return only Jetton transfer events.
|
|
87
|
+
|
|
88
|
+
Internally calls :meth:`get_transactions` and filters messages whose
|
|
89
|
+
op_code matches a TEP-74 Jetton transfer or transfer_notification.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
address: TON account address to query.
|
|
93
|
+
limit: Maximum number of transactions to scan (not transfers).
|
|
94
|
+
before_lt: Return transactions with logical time below this value.
|
|
95
|
+
decimals: Token decimal places used for amount normalization.
|
|
96
|
+
jetton_minter: Optional minter contract address to attach to results.
|
|
97
|
+
symbol: Optional token symbol to attach to results.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
List of :class:`~tonflow.models.JettonTransfer` in the order they
|
|
101
|
+
appear across the fetched transactions (newest transaction first,
|
|
102
|
+
matching the API order).
|
|
103
|
+
"""
|
|
104
|
+
transactions = await self.get_transactions(address, limit=limit, before_lt=before_lt)
|
|
105
|
+
|
|
106
|
+
transfers: list[JettonTransfer] = []
|
|
107
|
+
for tx in transactions:
|
|
108
|
+
messages: list[Message] = []
|
|
109
|
+
if tx.in_message is not None:
|
|
110
|
+
messages.append(tx.in_message)
|
|
111
|
+
messages.extend(tx.out_messages)
|
|
112
|
+
|
|
113
|
+
for msg in messages:
|
|
114
|
+
result = decode_jetton_transfer(
|
|
115
|
+
tx,
|
|
116
|
+
msg,
|
|
117
|
+
decimals=decimals,
|
|
118
|
+
jetton_minter=jetton_minter,
|
|
119
|
+
symbol=symbol,
|
|
120
|
+
)
|
|
121
|
+
if result is not None:
|
|
122
|
+
transfers.append(result)
|
|
123
|
+
|
|
124
|
+
return transfers
|
|
125
|
+
|
|
126
|
+
async def _request_json(
|
|
127
|
+
self,
|
|
128
|
+
method: str,
|
|
129
|
+
path: str,
|
|
130
|
+
*,
|
|
131
|
+
params: dict[str, int | None] | None = None,
|
|
132
|
+
) -> RawPayload:
|
|
133
|
+
clean_params = {key: value for key, value in (params or {}).items() if value is not None}
|
|
134
|
+
cache_key = _cache_key(method, path, clean_params)
|
|
135
|
+
if self.cache is not None and method.upper() == "GET":
|
|
136
|
+
cached = self.cache.get(cache_key)
|
|
137
|
+
if cached is not None:
|
|
138
|
+
return cached
|
|
139
|
+
|
|
140
|
+
client = self._get_http_client()
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
response = await client.request(method, path, params=clean_params)
|
|
144
|
+
response.raise_for_status()
|
|
145
|
+
except httpx.HTTPStatusError as exc:
|
|
146
|
+
raise TonflowAPIError(
|
|
147
|
+
f"TON API request failed with status {exc.response.status_code}.",
|
|
148
|
+
status_code=exc.response.status_code,
|
|
149
|
+
url=str(exc.request.url),
|
|
150
|
+
) from exc
|
|
151
|
+
except httpx.HTTPError as exc:
|
|
152
|
+
raise TonflowAPIError(f"TON API request failed: {exc}") from exc
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
data = response.json()
|
|
156
|
+
except ValueError as exc:
|
|
157
|
+
raise TonflowDecodeError("TON API response is not valid JSON.") from exc
|
|
158
|
+
|
|
159
|
+
if not isinstance(data, dict):
|
|
160
|
+
raise TonflowDecodeError("TON API response must be a JSON object.")
|
|
161
|
+
|
|
162
|
+
if self.cache is not None and method.upper() == "GET":
|
|
163
|
+
self.cache.set(cache_key, data, ttl_seconds=self.cache_ttl_seconds)
|
|
164
|
+
return data
|
|
165
|
+
|
|
166
|
+
def _get_http_client(self) -> httpx.AsyncClient:
|
|
167
|
+
if self.http_client is not None:
|
|
168
|
+
return self.http_client
|
|
169
|
+
|
|
170
|
+
headers = {"Accept": "application/json"}
|
|
171
|
+
if self.api_key is not None:
|
|
172
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
173
|
+
|
|
174
|
+
self.http_client = httpx.AsyncClient(
|
|
175
|
+
base_url=self.endpoint.rstrip("/"),
|
|
176
|
+
headers=headers,
|
|
177
|
+
timeout=self.timeout,
|
|
178
|
+
)
|
|
179
|
+
self._owns_http_client = True
|
|
180
|
+
return self.http_client
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _cache_key(method: str, path: str, params: dict[str, int]) -> str:
|
|
184
|
+
query = urlencode(sorted(params.items()))
|
|
185
|
+
if query:
|
|
186
|
+
return f"{method.upper()} {path}?{query}"
|
|
187
|
+
return f"{method.upper()} {path}"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _extract_transactions(payload: RawPayload) -> list[RawPayload]:
|
|
191
|
+
raw_transactions: Any = payload.get("transactions", payload.get("items"))
|
|
192
|
+
if raw_transactions is None:
|
|
193
|
+
raw_transactions = payload.get("result", [])
|
|
194
|
+
if not isinstance(raw_transactions, list):
|
|
195
|
+
raise TonflowDecodeError("TON API transactions field must be a list.")
|
|
196
|
+
|
|
197
|
+
transactions: list[RawPayload] = []
|
|
198
|
+
for item in raw_transactions:
|
|
199
|
+
if not isinstance(item, dict):
|
|
200
|
+
raise TonflowDecodeError("TON API transaction item must be an object.")
|
|
201
|
+
transactions.append(item)
|
|
202
|
+
return transactions
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _parse_transaction(raw: RawPayload, *, account: str) -> Transaction:
|
|
206
|
+
transaction_hash = _required_str(raw, "hash")
|
|
207
|
+
logical_time = _required_int(raw, "lt", fallback_keys=("logical_time",))
|
|
208
|
+
|
|
209
|
+
return Transaction(
|
|
210
|
+
hash=transaction_hash,
|
|
211
|
+
account=account,
|
|
212
|
+
lt=logical_time,
|
|
213
|
+
timestamp=_optional_int(raw, "now", fallback_keys=("timestamp", "utime")),
|
|
214
|
+
status=_parse_status(raw),
|
|
215
|
+
in_message=_parse_message(
|
|
216
|
+
raw.get("in_msg") or raw.get("in_message"), MessageDirection.INBOUND
|
|
217
|
+
),
|
|
218
|
+
out_messages=tuple(
|
|
219
|
+
_parse_required_message(item, MessageDirection.OUTBOUND)
|
|
220
|
+
for item in _raw_message_list(raw.get("out_msgs") or raw.get("out_messages"))
|
|
221
|
+
),
|
|
222
|
+
total_fees=_optional_int(raw, "total_fees", fallback_keys=("fee",)),
|
|
223
|
+
raw=raw,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _parse_message(raw: object, direction: MessageDirection) -> Message | None:
|
|
228
|
+
if raw is None:
|
|
229
|
+
return None
|
|
230
|
+
if not isinstance(raw, dict):
|
|
231
|
+
raise TonflowDecodeError("TON API message must be an object.")
|
|
232
|
+
|
|
233
|
+
return Message(
|
|
234
|
+
source=_optional_str(raw, "source", fallback_keys=("src",)),
|
|
235
|
+
destination=_optional_str(raw, "destination", fallback_keys=("dst",)),
|
|
236
|
+
direction=direction,
|
|
237
|
+
value=_optional_int(raw, "value", fallback_keys=("amount",)),
|
|
238
|
+
body=_optional_body(raw),
|
|
239
|
+
op_code=_optional_int(raw, "op_code", fallback_keys=("opcode",)),
|
|
240
|
+
raw=raw,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _parse_required_message(raw: object, direction: MessageDirection) -> Message:
|
|
245
|
+
message = _parse_message(raw, direction)
|
|
246
|
+
if message is None:
|
|
247
|
+
raise TonflowDecodeError("TON API message cannot be null in this field.")
|
|
248
|
+
return message
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _raw_message_list(raw: object) -> list[object]:
|
|
252
|
+
if raw is None:
|
|
253
|
+
return []
|
|
254
|
+
if not isinstance(raw, list):
|
|
255
|
+
raise TonflowDecodeError("TON API out messages field must be a list.")
|
|
256
|
+
return raw
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _parse_status(raw: RawPayload) -> TransactionStatus:
|
|
260
|
+
if raw.get("success") is True:
|
|
261
|
+
return TransactionStatus.SUCCESS
|
|
262
|
+
if raw.get("success") is False:
|
|
263
|
+
return TransactionStatus.FAILED
|
|
264
|
+
|
|
265
|
+
status = _optional_str(raw, "status")
|
|
266
|
+
if status in {TransactionStatus.SUCCESS, "ok"}:
|
|
267
|
+
return TransactionStatus.SUCCESS
|
|
268
|
+
if status in {TransactionStatus.FAILED, "error"}:
|
|
269
|
+
return TransactionStatus.FAILED
|
|
270
|
+
return TransactionStatus.UNKNOWN
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _optional_body(raw: RawPayload) -> str | None:
|
|
274
|
+
body = _optional_str(raw, "body")
|
|
275
|
+
if body is not None:
|
|
276
|
+
return body
|
|
277
|
+
|
|
278
|
+
decoded_body = raw.get("decoded_body")
|
|
279
|
+
if isinstance(decoded_body, dict):
|
|
280
|
+
text = decoded_body.get("text") or decoded_body.get("comment")
|
|
281
|
+
if isinstance(text, str):
|
|
282
|
+
return text
|
|
283
|
+
return None
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _required_str(raw: RawPayload, key: str, *, fallback_keys: tuple[str, ...] = ()) -> str:
|
|
287
|
+
value = _optional_str(raw, key, fallback_keys=fallback_keys)
|
|
288
|
+
if value is None:
|
|
289
|
+
raise TonflowDecodeError(f"TON API transaction is missing required field '{key}'.")
|
|
290
|
+
return value
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _optional_str(raw: RawPayload, key: str, *, fallback_keys: tuple[str, ...] = ()) -> str | None:
|
|
294
|
+
value = _first_value(raw, key, fallback_keys)
|
|
295
|
+
if value is None:
|
|
296
|
+
return None
|
|
297
|
+
if not isinstance(value, str):
|
|
298
|
+
raise TonflowDecodeError(f"TON API field '{key}' must be a string.")
|
|
299
|
+
return value
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _required_int(raw: RawPayload, key: str, *, fallback_keys: tuple[str, ...] = ()) -> int:
|
|
303
|
+
value = _optional_int(raw, key, fallback_keys=fallback_keys)
|
|
304
|
+
if value is None:
|
|
305
|
+
raise TonflowDecodeError(f"TON API transaction is missing required field '{key}'.")
|
|
306
|
+
return value
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _optional_int(raw: RawPayload, key: str, *, fallback_keys: tuple[str, ...] = ()) -> int | None:
|
|
310
|
+
value = _first_value(raw, key, fallback_keys)
|
|
311
|
+
if value is None:
|
|
312
|
+
return None
|
|
313
|
+
if isinstance(value, int):
|
|
314
|
+
return value
|
|
315
|
+
if isinstance(value, float) and value.is_integer():
|
|
316
|
+
return int(value)
|
|
317
|
+
if isinstance(value, str) and value.isdecimal():
|
|
318
|
+
return int(value)
|
|
319
|
+
raise TonflowDecodeError(f"TON API field '{key}' must be an integer.")
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _first_value(raw: RawPayload, key: str, fallback_keys: tuple[str, ...]) -> object:
|
|
323
|
+
if key in raw:
|
|
324
|
+
return raw[key]
|
|
325
|
+
for fallback_key in fallback_keys:
|
|
326
|
+
if fallback_key in raw:
|
|
327
|
+
return raw[fallback_key]
|
|
328
|
+
return None
|
tonflow/exceptions.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Custom exceptions raised by tonflow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TonflowError(Exception):
|
|
7
|
+
"""Base error for tonflow."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TonflowAPIError(TonflowError):
|
|
11
|
+
"""Raised when an upstream TON API request fails."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
message: str,
|
|
16
|
+
*,
|
|
17
|
+
status_code: int | None = None,
|
|
18
|
+
url: str | None = None,
|
|
19
|
+
) -> None:
|
|
20
|
+
super().__init__(message)
|
|
21
|
+
self.status_code = status_code
|
|
22
|
+
self.url = url
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TonflowDecodeError(TonflowError):
|
|
26
|
+
"""Raised when blockchain payload decoding fails."""
|
tonflow/export.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Helpers for exporting tonflow models to JSON and CSV."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
from decimal import Decimal
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from tonflow.models import JettonTransfer, Transaction
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _default(obj: object) -> Any:
|
|
15
|
+
if isinstance(obj, Decimal):
|
|
16
|
+
return str(obj)
|
|
17
|
+
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def transactions_to_json(transactions: list[Transaction], *, indent: int | None = None) -> str:
|
|
21
|
+
"""Serialize a list of transactions to a JSON string.
|
|
22
|
+
|
|
23
|
+
Decimal values are serialized as strings to preserve precision.
|
|
24
|
+
"""
|
|
25
|
+
data = [tx.model_dump(mode="json") for tx in transactions]
|
|
26
|
+
return json.dumps(data, default=_default, indent=indent, ensure_ascii=False)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def jetton_transfers_to_json(transfers: list[JettonTransfer], *, indent: int | None = None) -> str:
|
|
30
|
+
"""Serialize a list of Jetton transfers to a JSON string."""
|
|
31
|
+
data = [t.model_dump(mode="json") for t in transfers]
|
|
32
|
+
return json.dumps(data, default=_default, indent=indent, ensure_ascii=False)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# CSV column ordering for each model type
|
|
36
|
+
_TRANSACTION_FIELDS = [
|
|
37
|
+
"hash",
|
|
38
|
+
"account",
|
|
39
|
+
"logical_time",
|
|
40
|
+
"timestamp",
|
|
41
|
+
"status",
|
|
42
|
+
"total_fees",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
_JETTON_TRANSFER_FIELDS = [
|
|
46
|
+
"transaction_hash",
|
|
47
|
+
"sender",
|
|
48
|
+
"recipient",
|
|
49
|
+
"amount",
|
|
50
|
+
"raw_amount",
|
|
51
|
+
"decimals",
|
|
52
|
+
"symbol",
|
|
53
|
+
"jetton_wallet",
|
|
54
|
+
"jetton_minter",
|
|
55
|
+
"comment",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def transactions_to_csv(transactions: list[Transaction]) -> str:
|
|
60
|
+
"""Serialize a list of transactions to a CSV string.
|
|
61
|
+
|
|
62
|
+
Includes the core scalar fields; nested messages and raw payload are omitted.
|
|
63
|
+
"""
|
|
64
|
+
output = io.StringIO()
|
|
65
|
+
writer = csv.DictWriter(output, fieldnames=_TRANSACTION_FIELDS, extrasaction="ignore")
|
|
66
|
+
writer.writeheader()
|
|
67
|
+
for tx in transactions:
|
|
68
|
+
writer.writerow(
|
|
69
|
+
{
|
|
70
|
+
"hash": tx.hash,
|
|
71
|
+
"account": tx.account,
|
|
72
|
+
"logical_time": tx.logical_time,
|
|
73
|
+
"timestamp": tx.timestamp,
|
|
74
|
+
"status": tx.status,
|
|
75
|
+
"total_fees": tx.total_fees,
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
return output.getvalue()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def jetton_transfers_to_csv(transfers: list[JettonTransfer]) -> str:
|
|
82
|
+
"""Serialize a list of Jetton transfers to a CSV string."""
|
|
83
|
+
output = io.StringIO()
|
|
84
|
+
writer = csv.DictWriter(output, fieldnames=_JETTON_TRANSFER_FIELDS, extrasaction="ignore")
|
|
85
|
+
writer.writeheader()
|
|
86
|
+
for t in transfers:
|
|
87
|
+
writer.writerow(
|
|
88
|
+
{
|
|
89
|
+
"transaction_hash": t.transaction_hash,
|
|
90
|
+
"sender": t.sender,
|
|
91
|
+
"recipient": t.recipient,
|
|
92
|
+
"amount": str(t.amount),
|
|
93
|
+
"raw_amount": t.raw_amount,
|
|
94
|
+
"decimals": t.decimals,
|
|
95
|
+
"symbol": t.symbol,
|
|
96
|
+
"jetton_wallet": t.jetton_wallet,
|
|
97
|
+
"jetton_minter": t.jetton_minter,
|
|
98
|
+
"comment": t.comment,
|
|
99
|
+
}
|
|
100
|
+
)
|
|
101
|
+
return output.getvalue()
|
tonflow/jettons.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Jetton event normalization helpers."""
|
|
2
|
+
|
|
3
|
+
from decimal import Decimal
|
|
4
|
+
|
|
5
|
+
from tonflow.models import JettonTransfer, Message, Transaction
|
|
6
|
+
|
|
7
|
+
# TEP-74 Jetton standard op codes
|
|
8
|
+
OP_JETTON_TRANSFER = 0xF8A7EA5
|
|
9
|
+
OP_JETTON_TRANSFER_NOTIFICATION = 0x7362D09C
|
|
10
|
+
OP_JETTON_INTERNAL_TRANSFER = 0x178D4519
|
|
11
|
+
OP_JETTON_BURN = 0x595F07BC
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def normalize_amount(raw_amount: int | str, decimals: int) -> Decimal:
|
|
15
|
+
"""Convert a raw Jetton amount into a human-readable decimal value."""
|
|
16
|
+
amount = Decimal(str(raw_amount))
|
|
17
|
+
scale = Decimal(10) ** decimals
|
|
18
|
+
return amount / scale
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def is_jetton_transfer(message: Message) -> bool:
|
|
22
|
+
"""Return True if the message op_code matches a Jetton transfer."""
|
|
23
|
+
return message.op_code == OP_JETTON_TRANSFER
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def is_jetton_transfer_notification(message: Message) -> bool:
|
|
27
|
+
"""Return True if op_code matches a Jetton transfer notification."""
|
|
28
|
+
return message.op_code == OP_JETTON_TRANSFER_NOTIFICATION
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def is_jetton_burn(message: Message) -> bool:
|
|
32
|
+
"""Return True if op_code matches a Jetton burn."""
|
|
33
|
+
return message.op_code == OP_JETTON_BURN
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def decode_jetton_transfer(
|
|
37
|
+
transaction: Transaction,
|
|
38
|
+
message: Message,
|
|
39
|
+
*,
|
|
40
|
+
decimals: int = 9,
|
|
41
|
+
jetton_minter: str | None = None,
|
|
42
|
+
symbol: str | None = None,
|
|
43
|
+
) -> JettonTransfer | None:
|
|
44
|
+
"""Parse a Jetton transfer from a transaction message.
|
|
45
|
+
|
|
46
|
+
Returns a JettonTransfer if the message is a recognized Jetton transfer,
|
|
47
|
+
otherwise returns None.
|
|
48
|
+
|
|
49
|
+
Supports both TEP-74 transfer (op 0xf8a7ea5) and transfer_notification
|
|
50
|
+
(op 0x7362d09c). The raw payload is expected to carry the token amount
|
|
51
|
+
under the key ``"amount"`` and optionally ``"comment"`` / ``"forward_payload"``.
|
|
52
|
+
"""
|
|
53
|
+
if message.op_code not in (OP_JETTON_TRANSFER, OP_JETTON_TRANSFER_NOTIFICATION):
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
raw = message.raw
|
|
57
|
+
|
|
58
|
+
# Amount may come from the message value or an explicit ``amount`` field in
|
|
59
|
+
# the decoded payload (TonAPI style).
|
|
60
|
+
raw_amount_value = raw.get("amount") or raw.get("jetton_amount")
|
|
61
|
+
if raw_amount_value is None:
|
|
62
|
+
raw_amount_value = message.value or 0
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
raw_amount = int(raw_amount_value)
|
|
66
|
+
except (TypeError, ValueError):
|
|
67
|
+
raw_amount = 0
|
|
68
|
+
|
|
69
|
+
# Sender/recipient depend on message direction:
|
|
70
|
+
# - transfer (0xf8a7ea5): sender = message.source (Jetton wallet owner)
|
|
71
|
+
# - transfer_notification (0x7362d09c): recipient is the destination contract
|
|
72
|
+
sender = raw.get("sender") or message.source
|
|
73
|
+
recipient = raw.get("recipient") or raw.get("destination") or message.destination
|
|
74
|
+
|
|
75
|
+
comment: str | None = None
|
|
76
|
+
forward = raw.get("forward_payload") or raw.get("comment")
|
|
77
|
+
if isinstance(forward, str) and forward:
|
|
78
|
+
comment = forward
|
|
79
|
+
|
|
80
|
+
return JettonTransfer(
|
|
81
|
+
transaction_hash=transaction.hash,
|
|
82
|
+
sender=sender,
|
|
83
|
+
recipient=recipient,
|
|
84
|
+
amount=normalize_amount(raw_amount, decimals),
|
|
85
|
+
raw_amount=raw_amount,
|
|
86
|
+
decimals=decimals,
|
|
87
|
+
jetton_wallet=(
|
|
88
|
+
message.destination if message.op_code == OP_JETTON_TRANSFER else message.source
|
|
89
|
+
),
|
|
90
|
+
jetton_minter=jetton_minter,
|
|
91
|
+
symbol=symbol,
|
|
92
|
+
comment=comment,
|
|
93
|
+
raw=dict(raw),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def extract_jetton_transfers(
|
|
98
|
+
transaction: Transaction,
|
|
99
|
+
*,
|
|
100
|
+
decimals: int = 9,
|
|
101
|
+
jetton_minter: str | None = None,
|
|
102
|
+
symbol: str | None = None,
|
|
103
|
+
) -> list[JettonTransfer]:
|
|
104
|
+
"""Extract all Jetton transfers from a transaction's messages.
|
|
105
|
+
|
|
106
|
+
Scans both the inbound message and all outbound messages.
|
|
107
|
+
"""
|
|
108
|
+
transfers: list[JettonTransfer] = []
|
|
109
|
+
|
|
110
|
+
messages: list[Message] = []
|
|
111
|
+
if transaction.in_message is not None:
|
|
112
|
+
messages.append(transaction.in_message)
|
|
113
|
+
messages.extend(transaction.out_messages)
|
|
114
|
+
|
|
115
|
+
for msg in messages:
|
|
116
|
+
result = decode_jetton_transfer(
|
|
117
|
+
transaction,
|
|
118
|
+
msg,
|
|
119
|
+
decimals=decimals,
|
|
120
|
+
jetton_minter=jetton_minter,
|
|
121
|
+
symbol=symbol,
|
|
122
|
+
)
|
|
123
|
+
if result is not None:
|
|
124
|
+
transfers.append(result)
|
|
125
|
+
|
|
126
|
+
return transfers
|
tonflow/models.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Core data models returned by tonflow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from decimal import Decimal
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
10
|
+
|
|
11
|
+
RawPayload = dict[str, Any]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TonflowModel(BaseModel):
|
|
15
|
+
"""Base model with stable serialization defaults."""
|
|
16
|
+
|
|
17
|
+
model_config = ConfigDict(frozen=True, populate_by_name=True)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MessageDirection(StrEnum):
|
|
21
|
+
"""Direction of a message relative to the indexed account."""
|
|
22
|
+
|
|
23
|
+
INBOUND = "inbound"
|
|
24
|
+
OUTBOUND = "outbound"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TransactionStatus(StrEnum):
|
|
28
|
+
"""Normalized transaction execution status."""
|
|
29
|
+
|
|
30
|
+
SUCCESS = "success"
|
|
31
|
+
FAILED = "failed"
|
|
32
|
+
UNKNOWN = "unknown"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Message(TonflowModel):
|
|
36
|
+
"""Normalized TON message."""
|
|
37
|
+
|
|
38
|
+
source: str | None
|
|
39
|
+
destination: str | None
|
|
40
|
+
direction: MessageDirection | None = None
|
|
41
|
+
value: int | None = Field(default=None, ge=0)
|
|
42
|
+
body: str | None = None
|
|
43
|
+
op_code: int | None = Field(default=None, ge=0)
|
|
44
|
+
raw: RawPayload = Field(default_factory=dict)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Transaction(TonflowModel):
|
|
48
|
+
"""Normalized TON transaction."""
|
|
49
|
+
|
|
50
|
+
hash: str
|
|
51
|
+
account: str
|
|
52
|
+
logical_time: int = Field(alias="lt", ge=0)
|
|
53
|
+
timestamp: int | None = Field(default=None, ge=0)
|
|
54
|
+
status: TransactionStatus = TransactionStatus.UNKNOWN
|
|
55
|
+
in_message: Message | None = None
|
|
56
|
+
out_messages: tuple[Message, ...] = ()
|
|
57
|
+
total_fees: int | None = Field(default=None, ge=0)
|
|
58
|
+
raw: RawPayload = Field(default_factory=dict)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class JettonTransfer(TonflowModel):
|
|
62
|
+
"""Normalized Jetton transfer event."""
|
|
63
|
+
|
|
64
|
+
transaction_hash: str
|
|
65
|
+
sender: str | None
|
|
66
|
+
recipient: str | None
|
|
67
|
+
amount: Decimal = Field(ge=0)
|
|
68
|
+
raw_amount: int = Field(ge=0)
|
|
69
|
+
decimals: int = Field(ge=0, le=255)
|
|
70
|
+
jetton_wallet: str | None = None
|
|
71
|
+
jetton_minter: str | None = None
|
|
72
|
+
symbol: str | None = None
|
|
73
|
+
comment: str | None = None
|
|
74
|
+
raw: RawPayload = Field(default_factory=dict)
|
|
75
|
+
|
|
76
|
+
@field_validator("symbol")
|
|
77
|
+
@classmethod
|
|
78
|
+
def normalize_symbol(cls, value: str | None) -> str | None:
|
|
79
|
+
if value is None:
|
|
80
|
+
return None
|
|
81
|
+
normalized = value.strip()
|
|
82
|
+
return normalized or None
|
tonflow/stream.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Polling-based streaming of new TON transactions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import AsyncIterator
|
|
7
|
+
|
|
8
|
+
from tonflow.client import TonClient
|
|
9
|
+
from tonflow.models import Transaction
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def watch_address(
|
|
13
|
+
client: TonClient,
|
|
14
|
+
address: str,
|
|
15
|
+
*,
|
|
16
|
+
interval_seconds: float = 5.0,
|
|
17
|
+
lookback: int = 10,
|
|
18
|
+
) -> AsyncIterator[Transaction]:
|
|
19
|
+
"""Yield new transactions for *address* as they appear on-chain.
|
|
20
|
+
|
|
21
|
+
Polls ``client.get_transactions()`` every *interval_seconds* seconds.
|
|
22
|
+
On the first call fetches the last *lookback* transactions to establish
|
|
23
|
+
a baseline; subsequent polls only yield transactions with a logical time
|
|
24
|
+
greater than the highest seen so far, so duplicates are never emitted.
|
|
25
|
+
|
|
26
|
+
``watch_address`` runs indefinitely. To stop it after a fixed duration or
|
|
27
|
+
on an external signal, wrap it with :func:`asyncio.timeout` or cancel the
|
|
28
|
+
enclosing task:
|
|
29
|
+
|
|
30
|
+
.. code-block:: python
|
|
31
|
+
|
|
32
|
+
import asyncio
|
|
33
|
+
from tonflow import TonClient, watch_address
|
|
34
|
+
|
|
35
|
+
async def main() -> None:
|
|
36
|
+
async with TonClient(endpoint="https://tonapi.io") as client:
|
|
37
|
+
# Stop automatically after 60 seconds
|
|
38
|
+
async with asyncio.timeout(60):
|
|
39
|
+
async for tx in watch_address(client, "EQ..."):
|
|
40
|
+
print(tx.hash)
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
client: A configured :class:`TonClient` instance.
|
|
44
|
+
address: The TON address to watch.
|
|
45
|
+
interval_seconds: Seconds to wait between polls.
|
|
46
|
+
lookback: Number of recent transactions to fetch on the first poll
|
|
47
|
+
(used to seed the last-seen logical time without yielding old txs).
|
|
48
|
+
|
|
49
|
+
Yields:
|
|
50
|
+
:class:`Transaction` objects in ascending logical-time order.
|
|
51
|
+
"""
|
|
52
|
+
last_lt: int | None = None
|
|
53
|
+
|
|
54
|
+
# Seed: fetch recent transactions to set the baseline lt without yielding them.
|
|
55
|
+
seed = await client.get_transactions(address, limit=lookback)
|
|
56
|
+
if seed:
|
|
57
|
+
last_lt = max(tx.logical_time for tx in seed)
|
|
58
|
+
|
|
59
|
+
while True:
|
|
60
|
+
await asyncio.sleep(interval_seconds)
|
|
61
|
+
|
|
62
|
+
txs = await client.get_transactions(address, limit=lookback)
|
|
63
|
+
if not txs:
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
new_txs = [tx for tx in txs if last_lt is None or tx.logical_time > last_lt]
|
|
67
|
+
if not new_txs:
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
# Yield in ascending order (oldest first).
|
|
71
|
+
new_txs.sort(key=lambda tx: tx.logical_time)
|
|
72
|
+
for tx in new_txs:
|
|
73
|
+
yield tx
|
|
74
|
+
|
|
75
|
+
last_lt = max(tx.logical_time for tx in new_txs)
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tonflow
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python toolkit for reading, decoding, normalizing, and locally caching TON blockchain data.
|
|
5
|
+
Project-URL: Homepage, https://github.com/kakharov/tonflow
|
|
6
|
+
Project-URL: Repository, https://github.com/kakharov/tonflow
|
|
7
|
+
Project-URL: Issues, https://github.com/kakharov/tonflow/issues
|
|
8
|
+
Author-email: kakharov <kaharov95@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: blockchain,indexing,jetton,ton,web3
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Requires-Python: >=3.12
|
|
19
|
+
Requires-Dist: httpx>=0.27
|
|
20
|
+
Requires-Dist: pydantic>=2.7
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest>=8.2; extra == 'dev'
|
|
26
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# tonflow
|
|
30
|
+
|
|
31
|
+
[](https://github.com/kakharov/tonflow/actions/workflows/ci.yml)
|
|
32
|
+
[](https://codecov.io/gh/kakharov/tonflow)
|
|
33
|
+
[](https://pypi.org/project/tonflow/)
|
|
34
|
+
[](https://pypi.org/project/tonflow/)
|
|
35
|
+
[](LICENSE)
|
|
36
|
+
|
|
37
|
+
Python toolkit for reading, decoding, normalizing, and locally caching TON blockchain data.
|
|
38
|
+
|
|
39
|
+
`tonflow` is a lightweight MIT-licensed library. It does not run a hosted indexer and does not store blockchain data on your behalf — any cache lives on your own machine or infrastructure.
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install tonflow
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Requires Python 3.12+.
|
|
48
|
+
|
|
49
|
+
## Quickstart
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
import asyncio
|
|
53
|
+
from tonflow import TonClient
|
|
54
|
+
|
|
55
|
+
async def main() -> None:
|
|
56
|
+
async with TonClient(endpoint="https://tonapi.io") as client:
|
|
57
|
+
txs = await client.get_transactions("EQ...", limit=10)
|
|
58
|
+
for tx in txs:
|
|
59
|
+
print(tx.hash, tx.logical_time, tx.status)
|
|
60
|
+
|
|
61
|
+
asyncio.run(main())
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Recipes
|
|
65
|
+
|
|
66
|
+
### Get Jetton transfers
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
transfers = await client.get_jetton_transfers(
|
|
70
|
+
"EQ...",
|
|
71
|
+
limit=20,
|
|
72
|
+
decimals=6,
|
|
73
|
+
symbol="USDT",
|
|
74
|
+
)
|
|
75
|
+
for t in transfers:
|
|
76
|
+
print(t.sender, "→", t.recipient, t.amount, t.symbol)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Stream new transactions in real time
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from tonflow import watch_address
|
|
83
|
+
|
|
84
|
+
async for tx in watch_address(client, "EQ...", interval_seconds=5):
|
|
85
|
+
print("new tx:", tx.hash, tx.logical_time)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`watch_address` runs indefinitely. To stop it after a fixed duration, wrap it
|
|
89
|
+
with `asyncio.timeout` (Python 3.11+):
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
import asyncio
|
|
93
|
+
from tonflow import TonClient, watch_address
|
|
94
|
+
|
|
95
|
+
async def main() -> None:
|
|
96
|
+
async with TonClient(endpoint="https://tonapi.io") as client:
|
|
97
|
+
async with asyncio.timeout(60): # stop after 60 seconds
|
|
98
|
+
async for tx in watch_address(client, "EQ..."):
|
|
99
|
+
print(tx.hash)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Cache responses locally (avoid hammering public nodes)
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
from tonflow import SQLiteCache, TonClient
|
|
106
|
+
|
|
107
|
+
client = TonClient(
|
|
108
|
+
endpoint="https://tonapi.io",
|
|
109
|
+
cache=SQLiteCache(".tonflow/cache.sqlite3"),
|
|
110
|
+
cache_ttl_seconds=60,
|
|
111
|
+
)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Export to CSV or JSON
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
from tonflow import jetton_transfers_to_csv, transactions_to_json
|
|
118
|
+
|
|
119
|
+
json_str = transactions_to_json(txs, indent=2)
|
|
120
|
+
csv_str = jetton_transfers_to_csv(transfers)
|
|
121
|
+
|
|
122
|
+
with open("transfers.csv", "w") as f:
|
|
123
|
+
f.write(csv_str)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Validate a TON address
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
from tonflow import validate_address, is_user_friendly_address, is_raw_address
|
|
130
|
+
|
|
131
|
+
validate_address("EQ...") # raises ValueError if invalid
|
|
132
|
+
is_user_friendly_address("EQ...") # True / False
|
|
133
|
+
is_raw_address("0:abcd...") # True / False
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## API reference
|
|
137
|
+
|
|
138
|
+
### `TonClient`
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
TonClient(
|
|
142
|
+
endpoint: str,
|
|
143
|
+
api_key: str | None = None,
|
|
144
|
+
timeout: float = 10.0,
|
|
145
|
+
cache: JSONCache | None = None,
|
|
146
|
+
cache_ttl_seconds: float | None = 30.0,
|
|
147
|
+
)
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
| Method | Description |
|
|
151
|
+
|---|---|
|
|
152
|
+
| `get_transactions(address, limit, before_lt)` | Fetch and normalize account transactions |
|
|
153
|
+
| `get_jetton_transfers(address, limit, before_lt, decimals, jetton_minter, symbol)` | Fetch transactions and return only Jetton transfer events |
|
|
154
|
+
| `aclose()` | Close the underlying HTTP client |
|
|
155
|
+
|
|
156
|
+
Use as an async context manager (`async with`) for automatic cleanup.
|
|
157
|
+
|
|
158
|
+
### `watch_address`
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
watch_address(
|
|
162
|
+
client: TonClient,
|
|
163
|
+
address: str,
|
|
164
|
+
interval_seconds: float = 5.0,
|
|
165
|
+
lookback: int = 10,
|
|
166
|
+
) -> AsyncIterator[Transaction]
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Polls every `interval_seconds`. Seeds a baseline on the first call so existing transactions are not replayed. Yields new transactions in ascending logical-time order.
|
|
170
|
+
|
|
171
|
+
> **Note:** `watch_address` runs indefinitely. Use `asyncio.timeout()` or task cancellation to stop it.
|
|
172
|
+
|
|
173
|
+
### Models
|
|
174
|
+
|
|
175
|
+
| Model | Key fields |
|
|
176
|
+
|---|---|
|
|
177
|
+
| `Transaction` | `hash`, `account`, `logical_time`, `timestamp`, `status`, `in_message`, `out_messages`, `total_fees` |
|
|
178
|
+
| `Message` | `source`, `destination`, `direction`, `value`, `body`, `op_code` |
|
|
179
|
+
| `JettonTransfer` | `transaction_hash`, `sender`, `recipient`, `amount`, `raw_amount`, `decimals`, `symbol`, `jetton_wallet`, `jetton_minter`, `comment` |
|
|
180
|
+
|
|
181
|
+
### Cache backends
|
|
182
|
+
|
|
183
|
+
| Class | Storage | Best for |
|
|
184
|
+
|---|---|---|
|
|
185
|
+
| `InMemoryCache` | In-process dict | Tests, short-lived scripts |
|
|
186
|
+
| `SQLiteCache(path)` | SQLite file on disk | Local scripts, small services |
|
|
187
|
+
|
|
188
|
+
Both implement the `JSONCache` protocol — you can write your own backend (e.g. Redis) by implementing `get`, `set`, and `clear`.
|
|
189
|
+
|
|
190
|
+
### Export helpers
|
|
191
|
+
|
|
192
|
+
| Function | Output |
|
|
193
|
+
|---|---|
|
|
194
|
+
| `transactions_to_json(txs, indent=None)` | JSON string |
|
|
195
|
+
| `transactions_to_csv(txs)` | CSV string |
|
|
196
|
+
| `jetton_transfers_to_json(transfers, indent=None)` | JSON string |
|
|
197
|
+
| `jetton_transfers_to_csv(transfers)` | CSV string |
|
|
198
|
+
|
|
199
|
+
`Decimal` amounts are serialized as strings in both formats to preserve precision.
|
|
200
|
+
|
|
201
|
+
### Address helpers
|
|
202
|
+
|
|
203
|
+
| Function | Description |
|
|
204
|
+
|---|---|
|
|
205
|
+
| `normalize_address(addr)` | Strip whitespace, raise on empty |
|
|
206
|
+
| `is_user_friendly_address(addr)` | Validate EQ/UQ/kQ/0Q 48-char format |
|
|
207
|
+
| `is_raw_address(addr)` | Validate `workchain:64hexchars` format |
|
|
208
|
+
| `validate_address(addr)` | Accept either format, raise `ValueError` on invalid |
|
|
209
|
+
|
|
210
|
+
### Exceptions
|
|
211
|
+
|
|
212
|
+
| Exception | Raised when |
|
|
213
|
+
|---|---|
|
|
214
|
+
| `TonflowAPIError` | HTTP error from upstream TON API |
|
|
215
|
+
| `TonflowDecodeError` | API response cannot be parsed into expected models |
|
|
216
|
+
|
|
217
|
+
## Examples
|
|
218
|
+
|
|
219
|
+
See the [`examples/`](examples/) directory:
|
|
220
|
+
|
|
221
|
+
- [`get_transactions.py`](examples/get_transactions.py) — fetch and print recent transactions
|
|
222
|
+
- [`get_jetton_transfers.py`](examples/get_jetton_transfers.py) — fetch and print Jetton transfers
|
|
223
|
+
- [`watch_address.py`](examples/watch_address.py) — stream new transactions in real time
|
|
224
|
+
- [`export_to_csv.py`](examples/export_to_csv.py) — save transactions and transfers to CSV
|
|
225
|
+
- [`cache_with_sqlite.py`](examples/cache_with_sqlite.py) — local SQLite cache in action
|
|
226
|
+
|
|
227
|
+
## Development
|
|
228
|
+
|
|
229
|
+
```powershell
|
|
230
|
+
git clone https://github.com/kakharov/tonflow
|
|
231
|
+
cd tonflow
|
|
232
|
+
py -3.12 -m venv .venv
|
|
233
|
+
.venv\Scripts\Activate.ps1
|
|
234
|
+
pip install -e ".[dev]"
|
|
235
|
+
pytest
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Lint and type check:
|
|
239
|
+
|
|
240
|
+
```bash
|
|
241
|
+
ruff check .
|
|
242
|
+
ruff format .
|
|
243
|
+
mypy src/
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
## Roadmap
|
|
247
|
+
|
|
248
|
+
### `0.1.0` — current
|
|
249
|
+
- [x] `TonClient` with `get_transactions()` and `get_jetton_transfers()`
|
|
250
|
+
- [x] TEP-74 Jetton transfer decoder
|
|
251
|
+
- [x] `SQLiteCache` and `InMemoryCache` with TTL
|
|
252
|
+
- [x] `watch_address()` polling stream
|
|
253
|
+
- [x] Address validation (user-friendly and raw formats)
|
|
254
|
+
- [x] JSON and CSV export helpers
|
|
255
|
+
|
|
256
|
+
### `0.2.0` — planned
|
|
257
|
+
|
|
258
|
+
**TonCenter adapter**
|
|
259
|
+
|
|
260
|
+
TON ecosystem has multiple API providers with different trade-offs:
|
|
261
|
+
|
|
262
|
+
| Provider | Cost | Reliability | Notes |
|
|
263
|
+
|---|---|---|---|
|
|
264
|
+
| TonAPI | Paid | High | More endpoints, better rate limits |
|
|
265
|
+
| TonCenter | Free | Medium | Can drop transactions under load |
|
|
266
|
+
| Lite Server | Free | Highest | Direct node connection, complex setup |
|
|
267
|
+
|
|
268
|
+
`0.2.0` adds a pluggable provider system so you can switch between TonAPI and TonCenter without changing your code.
|
|
269
|
+
|
|
270
|
+
**`send_and_confirm()`**
|
|
271
|
+
|
|
272
|
+
TON is fully asynchronous — sending a transaction does not mean it was executed. Unlike Ethereum, there is no immediate transaction hash to track. Transactions can silently disappear from the mempool due to:
|
|
273
|
+
|
|
274
|
+
- `valid_until` TTL expiry (transaction not included in a block in time)
|
|
275
|
+
- TonCenter queue drops under high load
|
|
276
|
+
- `seqno` race conditions when sending in parallel
|
|
277
|
+
|
|
278
|
+
`send_and_confirm()` solves this by:
|
|
279
|
+
|
|
280
|
+
1. Sending the transaction via any configured provider
|
|
281
|
+
2. Polling every few seconds until the transaction appears on-chain
|
|
282
|
+
3. Automatically retrying with a fresh `seqno` if `valid_until` has passed and the transaction is gone
|
|
283
|
+
4. Returning only after the transaction is confirmed in a block
|
|
284
|
+
|
|
285
|
+
```python
|
|
286
|
+
result = await client.send_and_confirm(
|
|
287
|
+
wallet=wallet,
|
|
288
|
+
messages=[...],
|
|
289
|
+
timeout=60,
|
|
290
|
+
)
|
|
291
|
+
print(result.hash, result.logical_time)
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
**Full `0.2.0` scope**
|
|
295
|
+
|
|
296
|
+
- [ ] TonCenter adapter (pluggable provider interface)
|
|
297
|
+
- [ ] `send_and_confirm()` with polling, TTL tracking and automatic retry
|
|
298
|
+
- [ ] Websocket stream support (TonAPI / TonCenter)
|
|
299
|
+
- [ ] Jetton burn and mint event decoding
|
|
300
|
+
- [ ] Redis cache adapter
|
|
301
|
+
|
|
302
|
+
### `0.3.0` — planned
|
|
303
|
+
- [ ] NFT transfer event decoding
|
|
304
|
+
- [ ] CLI: `tonflow scan <address>`
|
|
305
|
+
- [ ] Postgres export helper
|
|
306
|
+
- [ ] Backfill utility for historical data
|
|
307
|
+
|
|
308
|
+
## License
|
|
309
|
+
|
|
310
|
+
MIT
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
tonflow/__init__.py,sha256=UD1Jj3Iiv3XiK8KMW4HmzclG26PC_U5Z7lIkWF0KPmY,1470
|
|
2
|
+
tonflow/addresses.py,sha256=Rn43EgijoTc1-tg38H57fcRfYd-LFijTUVuh9t8OZaY,2301
|
|
3
|
+
tonflow/cache.py,sha256=KmEfV7jYK2hEWw8T5Yy28MdNrbtVNK1_lvb2owGavhA,3898
|
|
4
|
+
tonflow/client.py,sha256=3xbq8Sc1OeHmKbvjJXVOW9u4OWtf7MJntk7LBr7v5p0,11481
|
|
5
|
+
tonflow/exceptions.py,sha256=clPpD2IB_agvtDr0bKy0DXeLNho6EyDztDS9cmTvTS8,590
|
|
6
|
+
tonflow/export.py,sha256=tt7_ETIQ9mYwiEFQ3A6lo8_Nu36SXH4UHTef-h8w9-8,3024
|
|
7
|
+
tonflow/jettons.py,sha256=Yaf6r3AVL5lKsWBc4zw9VsoP1qoHo091_Ssw4qeVGLY,4051
|
|
8
|
+
tonflow/models.py,sha256=WtO7bJvMauH5W7Kdez_IKDSkjUssR7e7ypfSlAxNhLQ,2198
|
|
9
|
+
tonflow/stream.py,sha256=sVaZQPe3qrMBxZxpyhrsNa7ZYsNMfd9ImChlHyDkSVA,2545
|
|
10
|
+
tonflow-0.1.0.dist-info/METADATA,sha256=C_lW1JRvMpRM5ad_rmVwIn-KZMgfPo-kLSoz1oZQPZ4,9715
|
|
11
|
+
tonflow-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
12
|
+
tonflow-0.1.0.dist-info/licenses/LICENSE,sha256=YblrJ3l-bZ36jPQrFh3z_ldlBI3BaFdtHPYFYL0iJM8,1065
|
|
13
|
+
tonflow-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 kakharov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|