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.
- decoded_shredstream/__init__.py +85 -0
- decoded_shredstream/_b58.py +19 -0
- decoded_shredstream/_client.py +49 -0
- decoded_shredstream/_codec.py +143 -0
- decoded_shredstream/_config.py +56 -0
- decoded_shredstream/_errors.py +23 -0
- decoded_shredstream/_filters.py +57 -0
- decoded_shredstream/_grpc.py +316 -0
- decoded_shredstream/_notices.py +46 -0
- decoded_shredstream/_pb/__init__.py +21 -0
- decoded_shredstream/_pb/decoded_pb2.py +37 -0
- decoded_shredstream/_pb/decoded_pb2_grpc.py +97 -0
- decoded_shredstream/_pb/shreder_binary_pb2.py +51 -0
- decoded_shredstream/_pb/shreder_binary_pb2_grpc.py +97 -0
- decoded_shredstream/_shortvec.py +37 -0
- decoded_shredstream/_stats.py +73 -0
- decoded_shredstream/_transaction.py +111 -0
- decoded_shredstream/_udp.py +117 -0
- decoded_shredstream/py.typed +0 -0
- decoded_shredstream-0.1.0.dist-info/METADATA +247 -0
- decoded_shredstream-0.1.0.dist-info/RECORD +23 -0
- decoded_shredstream-0.1.0.dist-info/WHEEL +4 -0
- decoded_shredstream-0.1.0.dist-info/licenses/LICENSE +218 -0
|
@@ -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)
|