decoded-shredstream 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.
@@ -0,0 +1,85 @@
1
+ """Client for the Decoded ShredStream of ShredStream.com: pre-execution Solana
2
+ transactions, decoded from shreds.
3
+ """
4
+
5
+ from ._client import Client
6
+ from ._codec import (
7
+ BAD_FRAGMENT,
8
+ BAD_MAGIC,
9
+ FRAME_HEADER_LEN,
10
+ FRAME_MAGIC,
11
+ FRAME_VERSION,
12
+ MAX_DATAGRAM,
13
+ MSG_EVENT,
14
+ MSG_TRANSACTION,
15
+ TOO_SHORT,
16
+ TRUNCATED,
17
+ UNSUPPORTED_VERSION,
18
+ FrameError,
19
+ FrameHeader,
20
+ Push,
21
+ StreamDecoder,
22
+ parse_header,
23
+ )
24
+ from ._config import DEFAULT_GRPC_PORT, GrpcConfig, ReconnectPolicy, UdpConfig
25
+ from ._errors import AuthRefused, ConnectError, InvalidFilter, Kicked, StreamError
26
+ from ._filters import Filter, validate_filters
27
+ from ._grpc import GrpcClient, normalize_endpoint
28
+ from ._notices import (
29
+ DecodeError,
30
+ Notice,
31
+ Reconnected,
32
+ Reconnecting,
33
+ RecvBufferClamped,
34
+ )
35
+ from ._shortvec import ShortVecError, decode_len, encode_len
36
+ from ._stats import StatsSnapshot
37
+ from ._transaction import TransactionUpdate
38
+ from ._udp import UdpClient
39
+
40
+ __version__ = "0.1.0"
41
+
42
+ __all__ = [
43
+ "Client",
44
+ "UdpClient",
45
+ "GrpcClient",
46
+ "UdpConfig",
47
+ "GrpcConfig",
48
+ "ReconnectPolicy",
49
+ "DEFAULT_GRPC_PORT",
50
+ "Filter",
51
+ "validate_filters",
52
+ "TransactionUpdate",
53
+ "StatsSnapshot",
54
+ "Notice",
55
+ "DecodeError",
56
+ "RecvBufferClamped",
57
+ "Reconnecting",
58
+ "Reconnected",
59
+ "StreamError",
60
+ "AuthRefused",
61
+ "Kicked",
62
+ "InvalidFilter",
63
+ "ConnectError",
64
+ "StreamDecoder",
65
+ "Push",
66
+ "FrameHeader",
67
+ "FrameError",
68
+ "parse_header",
69
+ "FRAME_MAGIC",
70
+ "FRAME_VERSION",
71
+ "FRAME_HEADER_LEN",
72
+ "MAX_DATAGRAM",
73
+ "MSG_EVENT",
74
+ "MSG_TRANSACTION",
75
+ "TOO_SHORT",
76
+ "BAD_MAGIC",
77
+ "UNSUPPORTED_VERSION",
78
+ "TRUNCATED",
79
+ "BAD_FRAGMENT",
80
+ "decode_len",
81
+ "encode_len",
82
+ "ShortVecError",
83
+ "normalize_endpoint",
84
+ "__version__",
85
+ ]
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ try: # pragma: no cover - exercised only when the extra is installed
4
+ import based58 as _based58
5
+
6
+ def b58encode(raw: bytes) -> str:
7
+ return _based58.b58encode(raw).decode("ascii")
8
+
9
+ def b58decode(text: str) -> bytes:
10
+ return _based58.b58decode(text.encode("ascii"))
11
+
12
+ except ImportError:
13
+ import base58 as _base58
14
+
15
+ def b58encode(raw: bytes) -> str:
16
+ return _base58.b58encode(raw).decode("ascii")
17
+
18
+ def b58decode(text: str) -> bytes:
19
+ return _base58.b58decode(text)
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Iterator
4
+
5
+ from ._config import GrpcConfig, UdpConfig
6
+ from ._notices import NoticeCallback
7
+ from ._stats import StatsSnapshot
8
+ from ._transaction import TransactionUpdate
9
+
10
+ if TYPE_CHECKING: # pragma: no cover
11
+ from ._grpc import GrpcClient
12
+ from ._udp import UdpClient
13
+
14
+ class Client:
15
+
16
+ __slots__ = ()
17
+
18
+ @staticmethod
19
+ def udp(config: UdpConfig) -> "UdpClient":
20
+ from ._udp import UdpClient
21
+
22
+ return UdpClient(config)
23
+
24
+ @staticmethod
25
+ def grpc(config: GrpcConfig) -> "GrpcClient":
26
+ from ._grpc import GrpcClient
27
+
28
+ return GrpcClient(config)
29
+
30
+ def __iter__(self) -> Iterator[TransactionUpdate]:
31
+ return self
32
+
33
+ def __next__(self) -> TransactionUpdate: # pragma: no cover - abstract
34
+ raise NotImplementedError
35
+
36
+ def on_notice(self, callback: NoticeCallback) -> None:
37
+ raise NotImplementedError # pragma: no cover
38
+
39
+ def stats(self) -> StatsSnapshot:
40
+ raise NotImplementedError # pragma: no cover
41
+
42
+ def close(self) -> None:
43
+ raise NotImplementedError # pragma: no cover
44
+
45
+ def __enter__(self) -> "Client":
46
+ return self
47
+
48
+ def __exit__(self, *exc) -> None:
49
+ self.close()
@@ -0,0 +1,143 @@
1
+ from __future__ import annotations
2
+
3
+ import struct
4
+ import time
5
+ from dataclasses import dataclass
6
+ from typing import Optional, Union
7
+
8
+ from ._notices import DecodeError, NoticeHook
9
+ from ._stats import Stats
10
+ from ._transaction import TransactionUpdate
11
+
12
+ FRAME_MAGIC = 0x5AE7
13
+ """Frame magic (little-endian on the wire)."""
14
+ FRAME_VERSION = 2
15
+ """Supported frame version."""
16
+ FRAME_HEADER_LEN = 16
17
+ """Header length, bytes."""
18
+ MAX_DATAGRAM = 1408
19
+ """Maximum datagram size emitted by the platform."""
20
+ MSG_EVENT = 1
21
+ """``msg_type`` of event frames (the Shred Event Stream product; skipped
22
+ by this client)."""
23
+ MSG_TRANSACTION = 2
24
+ """``msg_type`` of decoded-transaction frames (this client's stream)."""
25
+
26
+ _HEADER = struct.Struct("<HBBBBBBQ")
27
+ _U64 = struct.Struct("<Q")
28
+
29
+ _Buf = Union[bytes, bytearray, memoryview]
30
+
31
+ TOO_SHORT = "too_short"
32
+ BAD_MAGIC = "bad_magic"
33
+ UNSUPPORTED_VERSION = "unsupported_version"
34
+ TRUNCATED = "truncated"
35
+ BAD_FRAGMENT = "bad_fragment"
36
+
37
+ class FrameError(ValueError):
38
+
39
+ def __init__(self, code: str) -> None:
40
+ super().__init__(code)
41
+ self.code = code
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class FrameHeader:
45
+ version: int
46
+ msg_type: int
47
+ flags: int
48
+ frag_index: int
49
+ frag_count: int
50
+ seq: int
51
+
52
+ def parse_header(buf: _Buf) -> FrameHeader:
53
+ if len(buf) < FRAME_HEADER_LEN:
54
+ raise FrameError(TOO_SHORT)
55
+ magic, version, msg_type, flags, frag_index, frag_count, _pad, seq = _HEADER.unpack_from(
56
+ buf, 0
57
+ )
58
+ if magic != FRAME_MAGIC:
59
+ raise FrameError(BAD_MAGIC)
60
+ if version != FRAME_VERSION:
61
+ raise FrameError(UNSUPPORTED_VERSION)
62
+ return FrameHeader(
63
+ version=version,
64
+ msg_type=msg_type,
65
+ flags=flags,
66
+ frag_index=frag_index,
67
+ frag_count=frag_count,
68
+ seq=seq,
69
+ )
70
+
71
+ MESSAGE = "message"
72
+ SKIPPED = "skipped"
73
+ ERROR = "error"
74
+
75
+ class Push:
76
+
77
+ __slots__ = ("result", "transaction", "error")
78
+
79
+ def __init__(
80
+ self,
81
+ result: str,
82
+ transaction: Optional[TransactionUpdate] = None,
83
+ error: Optional[str] = None,
84
+ ):
85
+ self.result = result
86
+ self.transaction = transaction
87
+ self.error = error
88
+
89
+ def __repr__(self) -> str:
90
+ if self.result == MESSAGE:
91
+ return f"Push(message, {self.transaction!r})"
92
+ if self.result == ERROR:
93
+ return f"Push(error, {self.error})"
94
+ return f"Push({self.result})"
95
+
96
+ _PUSH_SKIPPED = Push(SKIPPED)
97
+
98
+ class StreamDecoder:
99
+
100
+ __slots__ = ("_stats", "_hook")
101
+
102
+ def __init__(self, stats: Optional[Stats] = None, hook: Optional[NoticeHook] = None) -> None:
103
+ self._stats = stats if stats is not None else Stats()
104
+ self._hook = hook if hook is not None else NoticeHook()
105
+
106
+ def stats(self):
107
+ return self._stats.snapshot()
108
+
109
+ def push(self, datagram: _Buf) -> Push:
110
+ stats = self._stats
111
+ stats.datagrams += 1
112
+ stats.bytes += len(datagram)
113
+
114
+ try:
115
+ header = parse_header(datagram)
116
+ except FrameError as e:
117
+ stats.decode_errors += 1
118
+ self._hook.fire(DecodeError())
119
+ return Push(ERROR, error=e.code)
120
+
121
+ if header.msg_type != MSG_TRANSACTION:
122
+ stats.skipped_msg_type += 1
123
+ return _PUSH_SKIPPED
124
+
125
+ if header.frag_count != 1:
126
+ stats.decode_errors += 1
127
+ self._hook.fire(DecodeError())
128
+ return Push(ERROR, error=BAD_FRAGMENT)
129
+
130
+ payload_len = len(datagram) - FRAME_HEADER_LEN
131
+ if payload_len < 8:
132
+ stats.decode_errors += 1
133
+ self._hook.fire(DecodeError())
134
+ return Push(ERROR, error=TRUNCATED)
135
+
136
+ slot = _U64.unpack_from(datagram, FRAME_HEADER_LEN)[0]
137
+ data = bytes(memoryview(datagram)[FRAME_HEADER_LEN + 8 :])
138
+ update = TransactionUpdate(slot=slot, data=data, received_at_s=time.time())
139
+
140
+ stats.messages += 1
141
+ stats.events += 1
142
+ stats.last_slot = slot
143
+ return Push(MESSAGE, transaction=update)
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Dict, Optional
5
+
6
+ from ._filters import Filter
7
+
8
+ DEFAULT_GRPC_PORT = 9991
9
+ """Default gRPC port when the endpoint omits one."""
10
+
11
+ @dataclass(slots=True)
12
+ class UdpConfig:
13
+
14
+ port: int = 0
15
+ """Local UDP port to bind (the port registered in your dashboard)."""
16
+ host: str = "0.0.0.0"
17
+ """Local address to bind."""
18
+ recv_buffer_bytes: int = 64 * 2**20
19
+ """Requested ``SO_RCVBUF``, in bytes. Default 64 MiB; keep at least
20
+ 8 MiB — slot boundaries produce brutal bursts. If the OS clamps the
21
+ value the client emits :class:`~decoded_shredstream.RecvBufferClamped`
22
+ (raise ``net.core.rmem_max`` on Linux, ``kern.ipc.maxsockbuf`` on
23
+ macOS)."""
24
+
25
+ @dataclass(slots=True)
26
+ class ReconnectPolicy:
27
+
28
+ initial: float = 0.1
29
+ """First retry delay, seconds."""
30
+ max: float = 5.0
31
+ """Delay cap, seconds."""
32
+ multiplier: float = 2.0
33
+ """Backoff multiplier."""
34
+ reset_after: float = 30.0
35
+ """Stable-connection duration (seconds) after which the backoff
36
+ resets."""
37
+
38
+ @dataclass(slots=True)
39
+ class GrpcConfig:
40
+
41
+ endpoint: str
42
+ """``host`` or ``host:port``. Port defaults to **9991** when
43
+ omitted."""
44
+ token: str
45
+ """Access token for this product."""
46
+ auth_style: str = "bearer"
47
+ """Token metadata style: ``"bearer"`` (``authorization: Bearer <t>``,
48
+ default) or ``"x-token"`` (``x-token: <t>``). Both are equivalent
49
+ server-side."""
50
+ filters: Dict[str, Filter] = field(default_factory=dict)
51
+ """Named server-side filters. Must not be empty (an empty map delivers
52
+ nothing) — use ``{"all": Filter()}`` to receive everything."""
53
+ reconnect: Optional[ReconnectPolicy] = None
54
+ """Reconnection policy (defaults to :class:`ReconnectPolicy`)."""
55
+ connect_timeout: float = 5.0
56
+ """Connection timeout, seconds."""
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ class StreamError(Exception):
4
+ pass
5
+
6
+ class AuthRefused(StreamError):
7
+
8
+ def __init__(self) -> None:
9
+ super().__init__("authentication refused (check your token and product)")
10
+
11
+ class Kicked(StreamError):
12
+
13
+ def __init__(self) -> None:
14
+ super().__init__("kicked by operator — do not reconnect in a loop")
15
+
16
+ class InvalidFilter(StreamError):
17
+
18
+ def __init__(self, detail: str) -> None:
19
+ super().__init__(f"invalid filter: {detail}")
20
+ self.detail = detail
21
+
22
+ class ConnectError(Exception):
23
+ pass
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Dict, Sequence
5
+
6
+ from ._b58 import b58decode
7
+ from ._errors import InvalidFilter
8
+
9
+ MAX_NAMED_FILTERS = 16
10
+ """Maximum named filters per subscription."""
11
+ MAX_KEYS_PER_FILTER = 1000
12
+ """Maximum keys per filter (include + exclude + required combined)."""
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class Filter:
16
+
17
+ include: Sequence[str] = field(default_factory=tuple)
18
+ """Match transactions touching AT LEAST ONE of these accounts."""
19
+ exclude: Sequence[str] = field(default_factory=tuple)
20
+ """Match transactions touching NONE of these accounts."""
21
+ required: Sequence[str] = field(default_factory=tuple)
22
+ """Match transactions touching ALL of these accounts."""
23
+
24
+ def key_count(self) -> int:
25
+ return len(self.include) + len(self.exclude) + len(self.required)
26
+
27
+ def _validate_keys(name: str, keys: Sequence[str]) -> None:
28
+ for key in keys:
29
+ try:
30
+ raw = b58decode(key)
31
+ except (ValueError, TypeError):
32
+ raw = b""
33
+ if len(raw) != 32:
34
+ raise InvalidFilter(
35
+ f"filter {name!r}: key {key!r} is not a base58-encoded 32-byte public key"
36
+ )
37
+
38
+ def validate_filters(filters: Dict[str, Filter]) -> None:
39
+ if not isinstance(filters, dict):
40
+ raise InvalidFilter("filters must be a dict of name -> Filter")
41
+ if not filters:
42
+ raise InvalidFilter(
43
+ "empty filter map delivers nothing — use Filter() to receive everything"
44
+ )
45
+ if len(filters) > MAX_NAMED_FILTERS:
46
+ raise InvalidFilter(f"{len(filters)} named filters (max {MAX_NAMED_FILTERS})")
47
+ for name, f in filters.items():
48
+ if not isinstance(f, Filter):
49
+ raise InvalidFilter(f"filter {name!r} is not a Filter")
50
+ count = f.key_count()
51
+ if count > MAX_KEYS_PER_FILTER:
52
+ raise InvalidFilter(
53
+ f"filter {name!r} has {count} keys (max {MAX_KEYS_PER_FILTER} per filter)"
54
+ )
55
+ _validate_keys(name, f.include)
56
+ _validate_keys(name, f.exclude)
57
+ _validate_keys(name, f.required)