tonutils 2.0.1b4__py3-none-any.whl → 2.0.1b6__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.
Files changed (47) hide show
  1. tonutils/__meta__.py +1 -1
  2. tonutils/clients/__init__.py +10 -10
  3. tonutils/clients/adnl/balancer.py +21 -24
  4. tonutils/clients/adnl/client.py +21 -24
  5. tonutils/clients/adnl/provider/config.py +22 -7
  6. tonutils/clients/base.py +22 -12
  7. tonutils/clients/http/__init__.py +11 -11
  8. tonutils/clients/http/balancer.py +11 -11
  9. tonutils/clients/http/clients/__init__.py +10 -10
  10. tonutils/clients/http/clients/chainstack.py +3 -3
  11. tonutils/clients/http/clients/quicknode.py +2 -3
  12. tonutils/clients/http/clients/tatum.py +3 -3
  13. tonutils/clients/http/clients/tonapi.py +9 -10
  14. tonutils/clients/http/clients/toncenter.py +50 -23
  15. tonutils/clients/http/{providers → provider}/__init__.py +4 -1
  16. tonutils/clients/http/{providers → provider}/base.py +71 -7
  17. tonutils/clients/http/{providers/toncenter → provider}/models.py +43 -1
  18. tonutils/clients/http/{providers/tonapi/provider.py → provider/tonapi.py} +8 -8
  19. tonutils/clients/http/{providers/toncenter/provider.py → provider/toncenter.py} +22 -14
  20. tonutils/clients/limiter.py +61 -59
  21. tonutils/clients/protocol.py +2 -2
  22. tonutils/contracts/wallet/base.py +2 -3
  23. tonutils/contracts/wallet/messages.py +4 -8
  24. tonutils/tonconnect/bridge/__init__.py +0 -0
  25. tonutils/tonconnect/events.py +0 -0
  26. tonutils/tonconnect/models/__init__.py +0 -0
  27. tonutils/tonconnect/storage.py +0 -0
  28. tonutils/tonconnect/tonconnect.py +0 -0
  29. tonutils/tools/block_scanner/__init__.py +2 -19
  30. tonutils/tools/block_scanner/events.py +48 -7
  31. tonutils/tools/block_scanner/scanner.py +315 -223
  32. tonutils/tools/block_scanner/storage.py +11 -0
  33. tonutils/utils.py +0 -48
  34. {tonutils-2.0.1b4.dist-info → tonutils-2.0.1b6.dist-info}/METADATA +1 -1
  35. {tonutils-2.0.1b4.dist-info → tonutils-2.0.1b6.dist-info}/RECORD +39 -41
  36. {tonutils-2.0.1b4.dist-info → tonutils-2.0.1b6.dist-info}/WHEEL +1 -1
  37. tonutils/clients/http/providers/response.py +0 -85
  38. tonutils/clients/http/providers/tonapi/__init__.py +0 -3
  39. tonutils/clients/http/providers/tonapi/models.py +0 -47
  40. tonutils/clients/http/providers/toncenter/__init__.py +0 -3
  41. tonutils/tools/block_scanner/annotations.py +0 -23
  42. tonutils/tools/block_scanner/dispatcher.py +0 -141
  43. tonutils/tools/block_scanner/traversal.py +0 -96
  44. tonutils/tools/block_scanner/where.py +0 -151
  45. {tonutils-2.0.1b4.dist-info → tonutils-2.0.1b6.dist-info}/entry_points.txt +0 -0
  46. {tonutils-2.0.1b4.dist-info → tonutils-2.0.1b6.dist-info}/licenses/LICENSE +0 -0
  47. {tonutils-2.0.1b4.dist-info → tonutils-2.0.1b6.dist-info}/top_level.txt +0 -0
@@ -1,138 +1,188 @@
1
1
  import asyncio
2
2
  import typing as t
3
- from dataclasses import dataclass
4
3
 
5
- from pytoniq_core import Transaction
6
4
  from pytoniq_core.tl import BlockIdExt
5
+ from pytoniq_core.tlb.block import ExtBlkRef
7
6
 
8
7
  from tonutils.clients import LiteBalancer, LiteClient
9
- from tonutils.tools.block_scanner.annotations import (
10
- BlockWhere,
11
- Decorator,
12
- Handler,
13
- TransactionWhere,
14
- TransactionsWhere,
15
- )
16
- from tonutils.tools.block_scanner.dispatcher import EventDispatcher
17
8
  from tonutils.tools.block_scanner.events import (
18
9
  BlockEvent,
19
- TransactionEvent,
10
+ ErrorEvent,
20
11
  TransactionsEvent,
21
12
  )
22
- from tonutils.tools.block_scanner.traversal import ShardTraversal
23
- from tonutils.types import WorkchainID, MASTERCHAIN_SHARD
24
-
13
+ from tonutils.tools.block_scanner.storage import BlockScannerStorageProtocol
14
+ from tonutils.types import MASTERCHAIN_SHARD, WorkchainID
25
15
 
26
- @dataclass(slots=True)
27
- class _ScanState:
28
- """Internal scanner state per masterchain block."""
16
+ ShardKey = t.Tuple[int, int]
17
+ SeenShardSeqno = t.Dict[ShardKey, int]
18
+ BlockQueue = asyncio.Queue[BlockIdExt]
29
19
 
30
- mc_block: BlockIdExt
31
- shards_seqno: t.Dict[t.Tuple[int, int], int]
20
+ OnError = t.Callable[[ErrorEvent], t.Awaitable[None]]
21
+ OnBlock = t.Callable[[BlockEvent], t.Awaitable[None]]
22
+ OnTransactions = t.Callable[[TransactionsEvent], t.Awaitable[None]]
32
23
 
33
24
 
34
25
  class BlockScanner:
35
- """Asynchronous scanner for TON blockchain."""
26
+ """
27
+ Asynchronous queue-based TON block scanner.
28
+
29
+ Discovers shard blocks by following masterchain shard tips, emits events for each
30
+ shard block, and optionally fetches transactions for shard blocks.
31
+
32
+ Handlers can be passed via constructor or set later via decorators:
33
+ - on_error: receives ErrorEvent
34
+ - on_block: receives BlockEvent
35
+ - on_transactions: receives TransactionsEvent
36
+ """
36
37
 
37
38
  def __init__(
38
39
  self,
39
- *,
40
40
  client: t.Union[LiteBalancer, LiteClient],
41
+ *,
42
+ on_error: t.Optional[OnError] = None,
43
+ on_block: t.Optional[OnBlock] = None,
44
+ on_transactions: t.Optional[OnTransactions] = None,
45
+ storage: t.Optional[BlockScannerStorageProtocol] = None,
41
46
  poll_interval: float = 0.1,
42
- include_transactions: bool = True,
43
- max_concurrency: int = 1000,
44
47
  **context: t.Any,
45
48
  ) -> None:
46
49
  """
47
- Initialize a BlockScanner.
48
-
49
- :param client: LiteClient or LiteBalancer instance for blockchain access.
50
- :param poll_interval: Interval in seconds to poll for new masterchain blocks.
51
- :param include_transactions: If True, emit TransactionEvent and TransactionsEvent.
52
- :param max_concurrency: Maximum number of concurrent event handler tasks.
53
- :param context: Additional key/value data passed to all emitted events.
50
+ Initialize scanner.
51
+
52
+ :param client: Lite client/balancer.
53
+ :param on_error: Called on internal errors and handler failures.
54
+ :param on_block: Called for each discovered shard block.
55
+ :param on_transactions: Called for shard blocks with fetched transactions.
56
+ :param storage: Progress storage (masterchain seqno).
57
+ :param poll_interval: Poll delay while waiting for next masterchain block.
58
+ :param context: Shared context passed to all events.
54
59
  """
55
60
  self._client = client
56
- self._context = dict(context)
61
+ self._on_error = on_error
62
+ self._on_block = on_block
63
+ self._on_transactions = on_transactions
64
+ self._storage = storage
57
65
  self._poll_interval = poll_interval
58
- self._include_transactions = include_transactions
59
-
60
- self._traversal = ShardTraversal()
61
- self._dispatcher = EventDispatcher(max_concurrency)
66
+ self._context = dict(context)
62
67
 
68
+ self._pending_blocks: BlockQueue = asyncio.Queue()
63
69
  self._stop_event = asyncio.Event()
64
70
  self._running = False
65
71
 
66
- @t.overload
67
- def register(
68
- self,
69
- event_type: t.Type[BlockEvent],
70
- handler: Handler[BlockEvent],
71
- *,
72
- where: t.Optional[BlockWhere] = None,
73
- ) -> None: ...
72
+ @staticmethod
73
+ def _shard_key(blk: BlockIdExt) -> ShardKey:
74
+ """Return shard key as (workchain, shard)."""
75
+ return blk.workchain, blk.shard
76
+
77
+ @staticmethod
78
+ def _overflow_i64(x: int) -> int:
79
+ """Wrap integer to signed 64-bit range."""
80
+ return (x + 2**63) % 2**64 - 2**63
81
+
82
+ @staticmethod
83
+ def _lowbit64(x: int) -> int:
84
+ """Return lowest set bit (64-bit shard math helper)."""
85
+ return x & (~x + 1)
86
+
87
+ def _child_shard(self, shard: int, *, left: bool) -> int:
88
+ """Return left/right child shard id for split shards."""
89
+ step = self._lowbit64(shard) >> 1
90
+ return self._overflow_i64(shard - step if left else shard + step)
91
+
92
+ def _parent_shard(self, shard: int) -> int:
93
+ """Return parent shard id for merged shards."""
94
+ step = self._lowbit64(shard)
95
+ return self._overflow_i64((shard - step) | (step << 1))
96
+
97
+ @property
98
+ def last_mc_block(self) -> BlockIdExt:
99
+ """Return last known masterchain block from provider cache."""
100
+ return self._client.provider.last_mc_block
74
101
 
75
- @t.overload
76
- def register(
77
- self,
78
- event_type: t.Type[TransactionEvent],
79
- handler: Handler[TransactionEvent],
80
- *,
81
- where: t.Optional[TransactionWhere] = None,
82
- ) -> None: ...
102
+ def on_error(self, fn: t.Optional[OnError] = None) -> t.Any:
103
+ """Decorator to set error handler."""
83
104
 
84
- @t.overload
85
- def register(
86
- self,
87
- event_type: t.Type[TransactionsEvent],
88
- handler: Handler[TransactionsEvent],
89
- *,
90
- where: t.Optional[TransactionsWhere] = None,
91
- ) -> None: ...
105
+ def decorator(handler: OnError) -> OnError:
106
+ self._on_error = handler
107
+ return handler
108
+
109
+ return decorator if fn is None else decorator(fn)
110
+
111
+ def on_block(self, fn: t.Optional[OnBlock] = None) -> t.Any:
112
+ """Decorator to set block handler."""
113
+
114
+ def decorator(handler: OnBlock) -> OnBlock:
115
+ self._on_block = handler
116
+ return handler
117
+
118
+ return decorator if fn is None else decorator(fn)
92
119
 
93
- def register(
120
+ def on_transactions(self, fn: t.Optional[OnTransactions] = None) -> t.Any:
121
+ """Decorator to set transactions handler."""
122
+
123
+ def decorator(handler: OnTransactions) -> OnTransactions:
124
+ self._on_transactions = handler
125
+ return handler
126
+
127
+ return decorator if fn is None else decorator(fn)
128
+
129
+ async def _call_error_handler(
94
130
  self,
95
- event_type: t.Any,
96
- handler: t.Any,
131
+ error: BaseException,
132
+ mc_block: BlockIdExt,
97
133
  *,
98
- where: t.Any = None,
134
+ event: t.Any = None,
135
+ handler: t.Any = None,
136
+ block: t.Optional[BlockIdExt] = None,
99
137
  ) -> None:
100
- """Register a handler for an event type with optional filter."""
101
- self._dispatcher.register(event_type, handler, where=where)
102
-
103
- def on_block(
104
- self,
105
- where: t.Optional[BlockWhere] = None,
106
- ) -> Decorator[BlockEvent]:
107
- """Decorator for block event handlers."""
108
- return self._dispatcher.on(BlockEvent, where=where)
138
+ """Call error handler with ErrorEvent. Never raises."""
139
+ if self._on_error is None:
140
+ return
109
141
 
110
- def on_transaction(
111
- self,
112
- where: t.Optional[TransactionWhere] = None,
113
- ) -> Decorator[TransactionEvent]:
114
- """Decorator for transaction event handlers."""
115
- return self._dispatcher.on(TransactionEvent, where=where)
142
+ try:
143
+ await self._on_error(
144
+ ErrorEvent(
145
+ client=self._client,
146
+ mc_block=mc_block,
147
+ context=self._context,
148
+ error=error,
149
+ event=event,
150
+ handler=handler,
151
+ block=block,
152
+ )
153
+ )
154
+ except asyncio.CancelledError:
155
+ raise
156
+ except (BaseException,):
157
+ return
116
158
 
117
- def on_transactions(
118
- self,
119
- where: t.Optional[TransactionsWhere] = None,
120
- ) -> Decorator[TransactionsEvent]:
121
- """Decorator for batch transaction event handlers."""
122
- return self._dispatcher.on(TransactionsEvent, where=where)
159
+ async def _call_handler(self, handler: t.Any, event: t.Any) -> None:
160
+ """Call handler(event). Route failures to on_error."""
161
+ if handler is None:
162
+ return
123
163
 
124
- def _get_last_mc_block(self) -> BlockIdExt:
125
- """Return last masterchain block."""
126
- return self._client.provider.last_mc_block
164
+ try:
165
+ await handler(event)
166
+ except asyncio.CancelledError:
167
+ raise
168
+ except BaseException as error:
169
+ await self._call_error_handler(
170
+ error,
171
+ event.mc_block,
172
+ event=event,
173
+ handler=handler,
174
+ block=event.block,
175
+ )
127
176
 
128
177
  async def _lookup_mc_block(
129
178
  self,
179
+ *,
130
180
  seqno: t.Optional[int] = None,
131
181
  lt: t.Optional[int] = None,
132
182
  utime: t.Optional[int] = None,
133
183
  ) -> BlockIdExt:
134
- """Lookup masterchain block by seqno, lt, or utime."""
135
- mc_block, _info = await self._client.lookup_block(
184
+ """Lookup masterchain block by seqno/lt/utime."""
185
+ mc_block, _ = await self._client.lookup_block(
136
186
  workchain=WorkchainID.MASTERCHAIN,
137
187
  shard=MASTERCHAIN_SHARD,
138
188
  seqno=seqno,
@@ -141,175 +191,217 @@ class BlockScanner:
141
191
  )
142
192
  return mc_block
143
193
 
144
- async def _init_state(
145
- self,
146
- seqno: t.Optional[int] = None,
147
- lt: t.Optional[int] = None,
148
- utime: t.Optional[int] = None,
149
- ) -> _ScanState:
150
- """Initialize scanning state."""
151
- if seqno is None and lt is None and utime is None:
152
- mc_block = self._get_last_mc_block()
153
- else:
154
- mc_block = await self._lookup_mc_block(seqno=seqno, lt=lt, utime=utime)
194
+ async def _wait_next_mc_block(self, mc_block: BlockIdExt) -> BlockIdExt:
195
+ """Wait until next masterchain block becomes available."""
196
+ next_mc_seqno = mc_block.seqno + 1
155
197
 
156
- if mc_block.seqno > 0:
157
- prev_mc = await self._lookup_mc_block(seqno=mc_block.seqno - 1)
158
- else:
159
- prev_mc = mc_block
198
+ while not self._stop_event.is_set():
199
+ last_mc_block = self.last_mc_block
200
+ if next_mc_seqno <= last_mc_block.seqno:
201
+ if next_mc_seqno == last_mc_block.seqno:
202
+ return last_mc_block
203
+ return await self._lookup_mc_block(seqno=next_mc_seqno)
160
204
 
161
- shards_seqno: t.Dict[t.Tuple[int, int], int] = {}
162
- for shard in await self._client.get_all_shards_info(prev_mc):
163
- shards_seqno[self._traversal.shard_key(shard)] = shard.seqno
205
+ await asyncio.sleep(self._poll_interval)
164
206
 
165
- return _ScanState(mc_block=mc_block, shards_seqno=shards_seqno)
207
+ return mc_block
166
208
 
167
- def _ensure_running(self) -> None:
168
- """Raise CancelledError if scanner was stopped."""
169
- if self._stop_event.is_set():
170
- raise asyncio.CancelledError("Block scanner stopped")
209
+ async def _get_seen_shard_seqno(self, mc_block: BlockIdExt) -> SeenShardSeqno:
210
+ """Build map of last processed shard seqno from previous masterchain block."""
211
+ seen_shard_seqno: SeenShardSeqno = {}
212
+ if mc_block.seqno <= 0:
213
+ return seen_shard_seqno
171
214
 
172
- async def _collect_blocks(
173
- self,
174
- mc_block: BlockIdExt,
175
- shards_seqno: t.Dict[t.Tuple[int, int], int],
176
- ) -> t.List[BlockIdExt]:
177
- """Collect all unseen shard blocks for a masterchain block."""
178
- shards = await self._client.get_all_shards_info(mc_block)
179
-
180
- blocks: t.List[BlockIdExt] = []
181
- for shard_tip in shards:
182
- blocks.extend(
183
- await self._traversal.walk_unseen(
184
- root=shard_tip,
185
- seen_seqno=shards_seqno,
186
- get_header=self._client.get_block_header,
187
- )
188
- )
215
+ prev_mc_block = await self._lookup_mc_block(seqno=mc_block.seqno - 1)
216
+ for shard_tip in await self._client.get_all_shards_info(prev_mc_block):
217
+ seen_shard_seqno[self._shard_key(shard_tip)] = shard_tip.seqno
189
218
 
190
- blocks.sort(key=lambda b: (b.workchain, b.shard, b.seqno))
191
- return blocks
219
+ return seen_shard_seqno
192
220
 
193
- def _emit_block(self, mc_block: BlockIdExt, block: BlockIdExt) -> None:
194
- """Emit block event."""
195
- self._dispatcher.emit(
196
- BlockEvent(
197
- mc_block=mc_block,
221
+ async def _process_pending_blocks(self, mc_block: BlockIdExt) -> None:
222
+ """Process queued shard blocks and emit events."""
223
+ while not self._stop_event.is_set():
224
+ try:
225
+ shard_block = self._pending_blocks.get_nowait()
226
+ except asyncio.QueueEmpty:
227
+ return
228
+
229
+ block_event = BlockEvent(
198
230
  client=self._client,
231
+ mc_block=mc_block,
232
+ block=shard_block,
199
233
  context=self._context,
200
- block=block,
201
234
  )
202
- )
235
+ await self._call_handler(self._on_block, block_event)
236
+
237
+ if self._on_transactions is None:
238
+ continue
239
+
240
+ get_block_transactions = self._client.get_block_transactions_ext
241
+ try:
242
+ transactions = await get_block_transactions(shard_block)
243
+ except asyncio.CancelledError:
244
+ raise
245
+ except BaseException as error:
246
+ await self._call_error_handler(
247
+ error,
248
+ mc_block,
249
+ event=block_event,
250
+ handler=get_block_transactions,
251
+ block=shard_block,
252
+ )
253
+ transactions = []
203
254
 
204
- def _emit_transactions(
205
- self,
206
- mc_block: BlockIdExt,
207
- block: BlockIdExt,
208
- transactions: t.List[Transaction],
209
- ) -> None:
210
- """Emit batch transactions event."""
211
- self._dispatcher.emit(
212
- TransactionsEvent(
213
- mc_block=mc_block,
255
+ transactions_event = TransactionsEvent(
214
256
  client=self._client,
215
- context=self._context,
216
- block=block,
257
+ mc_block=mc_block,
258
+ block=shard_block,
217
259
  transactions=transactions,
260
+ context=self._context,
218
261
  )
219
- )
262
+ await self._call_handler(self._on_transactions, transactions_event)
220
263
 
221
- def _emit_transaction(
264
+ async def _enqueue_missing_blocks(
222
265
  self,
223
- mc_block: BlockIdExt,
224
- block: BlockIdExt,
225
- transaction: Transaction,
266
+ shard_tip: BlockIdExt,
267
+ seen_seqno: SeenShardSeqno,
226
268
  ) -> None:
227
- """Emit single transaction event."""
228
- self._dispatcher.emit(
229
- TransactionEvent(
230
- mc_block=mc_block,
231
- client=self._client,
232
- context=self._context,
233
- block=block,
234
- transaction=transaction,
269
+ """Enqueue unseen shard blocks in order (oldest -> newest)."""
270
+ shard_id = self._shard_key(shard_tip)
271
+ if seen_seqno.get(shard_id, -1) >= shard_tip.seqno:
272
+ return
273
+
274
+ _, header = await self._client.get_block_header(shard_tip)
275
+ info = header.info
276
+ prev_ref = info.prev_ref
277
+
278
+ if prev_ref.type_ == "prev_blk_info":
279
+ prev: ExtBlkRef = prev_ref.prev
280
+ prev_shard = (
281
+ self._parent_shard(shard_tip.shard)
282
+ if info.after_split
283
+ else shard_tip.shard
235
284
  )
236
- )
237
285
 
238
- async def _handle_block(
239
- self,
240
- mc_block: BlockIdExt,
241
- block: BlockIdExt,
242
- shards_seqno: t.Dict[t.Tuple[int, int], int],
243
- ) -> None:
244
- """Process shard block and emit events for block + transactions."""
245
- self._ensure_running()
286
+ await self._enqueue_missing_blocks(
287
+ shard_tip=BlockIdExt(
288
+ workchain=shard_tip.workchain,
289
+ shard=prev_shard,
290
+ seqno=prev.seqno,
291
+ root_hash=prev.root_hash,
292
+ file_hash=prev.file_hash,
293
+ ),
294
+ seen_seqno=seen_seqno,
295
+ )
296
+ else:
297
+ prev1, prev2 = prev_ref.prev1, prev_ref.prev2
298
+
299
+ await self._enqueue_missing_blocks(
300
+ shard_tip=BlockIdExt(
301
+ workchain=shard_tip.workchain,
302
+ shard=self._child_shard(shard_tip.shard, left=True),
303
+ seqno=prev1.seqno,
304
+ root_hash=prev1.root_hash,
305
+ file_hash=prev1.file_hash,
306
+ ),
307
+ seen_seqno=seen_seqno,
308
+ )
309
+ await self._enqueue_missing_blocks(
310
+ shard_tip=BlockIdExt(
311
+ workchain=shard_tip.workchain,
312
+ shard=self._child_shard(shard_tip.shard, left=False),
313
+ seqno=prev2.seqno,
314
+ root_hash=prev2.root_hash,
315
+ file_hash=prev2.file_hash,
316
+ ),
317
+ seen_seqno=seen_seqno,
318
+ )
246
319
 
247
- shards_seqno[self._traversal.shard_key(block)] = block.seqno
248
- self._emit_block(mc_block, block)
320
+ await self._pending_blocks.put(shard_tip)
249
321
 
250
- if not self._include_transactions:
251
- return
322
+ async def _run(self, mc_block: BlockIdExt) -> None:
323
+ """Run scanning loop from provided masterchain block."""
324
+ if self._running:
325
+ raise RuntimeError("BlockScanner already running")
252
326
 
253
- transactions = await self._client.get_block_transactions_ext(block)
254
- self._emit_transactions(mc_block, block, transactions)
327
+ self._running = True
328
+ self._stop_event.clear()
255
329
 
256
- for transaction in transactions:
257
- self._ensure_running()
258
- self._emit_transaction(mc_block, block, transaction)
330
+ try:
331
+ seen_shard_seqno = await self._get_seen_shard_seqno(mc_block)
259
332
 
260
- async def _wait_next_mc_block(self, current: BlockIdExt) -> BlockIdExt:
261
- """Wait for next masterchain block, polling until available."""
262
- next_seqno = current.seqno + 1
333
+ while not self._stop_event.is_set():
334
+ for shard_tip in await self._client.get_all_shards_info(mc_block):
335
+ await self._enqueue_missing_blocks(shard_tip, seen_shard_seqno)
336
+ seen_shard_seqno[self._shard_key(shard_tip)] = shard_tip.seqno
263
337
 
264
- while True:
265
- self._ensure_running()
266
- last_mc_block = self._get_last_mc_block()
338
+ await self._process_pending_blocks(mc_block)
267
339
 
268
- if next_seqno <= last_mc_block.seqno:
269
- if next_seqno == last_mc_block.seqno:
270
- return last_mc_block
271
- return await self._lookup_mc_block(seqno=next_seqno)
340
+ if self._storage is not None:
341
+ await self._storage.set_mc_seqno(mc_block.seqno)
342
+ mc_block = await self._wait_next_mc_block(mc_block)
343
+ finally:
344
+ self._running = False
345
+ self._stop_event.set()
272
346
 
273
- await asyncio.sleep(self._poll_interval)
347
+ async def resume(self) -> None:
348
+ """Resume from storage."""
349
+ if self._storage is None:
350
+ raise RuntimeError("Storage is not configured")
351
+
352
+ saved_seqno = await self._storage.get_mc_seqno()
353
+ if saved_seqno is None or saved_seqno < 0:
354
+ raise RuntimeError("No masterchain seqno in storage")
274
355
 
275
- async def start(
356
+ last_mc_block = self.last_mc_block
357
+ if saved_seqno > last_mc_block.seqno:
358
+ raise RuntimeError("Storage masterchain seqno is ahead of network")
359
+
360
+ if saved_seqno >= last_mc_block.seqno:
361
+ mc_block = await self._wait_next_mc_block(last_mc_block)
362
+ else:
363
+ next_seqno = saved_seqno + 1
364
+ mc_block = (
365
+ last_mc_block
366
+ if next_seqno >= last_mc_block.seqno
367
+ else await self._lookup_mc_block(seqno=next_seqno)
368
+ )
369
+
370
+ await self._run(mc_block)
371
+
372
+ async def start_from(
276
373
  self,
277
- from_seqno: t.Optional[int] = None,
278
- from_lt: t.Optional[int] = None,
279
- from_utime: t.Optional[int] = None,
374
+ *,
375
+ seqno: t.Optional[int] = None,
376
+ utime: t.Optional[int] = None,
377
+ lt: t.Optional[int] = None,
280
378
  ) -> None:
281
379
  """
282
- Start scanning from the specified point.
380
+ Start scanning from an explicit masterchain point.
283
381
 
284
- :param from_seqno: start from specific masterchain sequence number.
285
- :param from_lt: start from specific logical time (LT) of a block.
286
- :param from_utime: start from specific Unix timestamp.
382
+ :param seqno: Masterchain seqno.
383
+ :param utime: Unix time (resolved to masterchain block).
384
+ :param lt: Logical time (resolved to masterchain block).
287
385
  """
288
- if self._running:
289
- raise RuntimeError("BlockScanner is already running")
386
+ provided = sum(v is not None for v in (seqno, utime, lt))
387
+ if provided != 1:
388
+ raise ValueError("Provide exactly one of seqno, utime, lt")
389
+
390
+ if seqno is not None:
391
+ mc_block = await self._lookup_mc_block(seqno=seqno)
392
+ elif utime is not None:
393
+ mc_block = await self._lookup_mc_block(utime=utime)
394
+ elif lt is not None:
395
+ mc_block = await self._lookup_mc_block(lt=lt)
396
+ else:
397
+ raise AssertionError("unreachable")
290
398
 
291
- self._running = True
292
- self._stop_event.clear()
399
+ await self._run(mc_block)
293
400
 
294
- state = await self._init_state(
295
- seqno=from_seqno,
296
- lt=from_lt,
297
- utime=from_utime,
298
- )
299
-
300
- try:
301
- while not self._stop_event.is_set():
302
- blocks = await self._collect_blocks(
303
- mc_block=state.mc_block,
304
- shards_seqno=state.shards_seqno,
305
- )
306
- for block in blocks:
307
- await self._handle_block(state.mc_block, block, state.shards_seqno)
308
- state.mc_block = await self._wait_next_mc_block(state.mc_block)
309
- finally:
310
- await self._dispatcher.aclose()
311
- self._running = False
401
+ async def start(self) -> None:
402
+ """Start from the current last masterchain block."""
403
+ await self._run(self.last_mc_block)
312
404
 
313
405
  async def stop(self) -> None:
314
- """Stop scanning."""
406
+ """Request scanner stop."""
315
407
  self._stop_event.set()
@@ -0,0 +1,11 @@
1
+ import typing as t
2
+
3
+
4
+ class BlockScannerStorageProtocol(t.Protocol):
5
+ """Store BlockScanner progress (masterchain seqno)."""
6
+
7
+ async def get_mc_seqno(self) -> t.Optional[int]:
8
+ """Return last processed masterchain seqno (or None)."""
9
+
10
+ async def set_mc_seqno(self, seqno: int) -> None:
11
+ """Persist last processed masterchain seqno."""