astreum 0.2.12__tar.gz → 0.2.50__tar.gz

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.

Potentially problematic release.


This version of astreum might be problematic. Click here for more details.

Files changed (73) hide show
  1. {astreum-0.2.12/src/astreum.egg-info → astreum-0.2.50}/PKG-INFO +51 -15
  2. {astreum-0.2.12 → astreum-0.2.50}/README.md +50 -14
  3. {astreum-0.2.12 → astreum-0.2.50}/pyproject.toml +1 -1
  4. astreum-0.2.50/src/astreum/__init__.py +9 -0
  5. astreum-0.2.50/src/astreum/_communication/__init__.py +11 -0
  6. astreum-0.2.50/src/astreum/_communication/message.py +101 -0
  7. astreum-0.2.50/src/astreum/_communication/peer.py +23 -0
  8. astreum-0.2.50/src/astreum/_communication/ping.py +33 -0
  9. astreum-0.2.50/src/astreum/_communication/route.py +95 -0
  10. astreum-0.2.50/src/astreum/_communication/setup.py +322 -0
  11. astreum-0.2.50/src/astreum/_communication/util.py +42 -0
  12. astreum-0.2.50/src/astreum/_consensus/__init__.py +20 -0
  13. astreum-0.2.50/src/astreum/_consensus/account.py +95 -0
  14. astreum-0.2.50/src/astreum/_consensus/accounts.py +38 -0
  15. astreum-0.2.50/src/astreum/_consensus/block.py +328 -0
  16. astreum-0.2.50/src/astreum/_consensus/chain.py +66 -0
  17. astreum-0.2.50/src/astreum/_consensus/fork.py +100 -0
  18. astreum-0.2.50/src/astreum/_consensus/genesis.py +72 -0
  19. astreum-0.2.50/src/astreum/_consensus/receipt.py +177 -0
  20. astreum-0.2.50/src/astreum/_consensus/setup.py +115 -0
  21. astreum-0.2.50/src/astreum/_consensus/transaction.py +216 -0
  22. astreum-0.2.50/src/astreum/_consensus/workers/__init__.py +9 -0
  23. astreum-0.2.50/src/astreum/_consensus/workers/discovery.py +48 -0
  24. astreum-0.2.50/src/astreum/_consensus/workers/validation.py +125 -0
  25. astreum-0.2.50/src/astreum/_consensus/workers/verify.py +63 -0
  26. astreum-0.2.50/src/astreum/_lispeum/__init__.py +16 -0
  27. astreum-0.2.50/src/astreum/_lispeum/environment.py +13 -0
  28. astreum-0.2.50/src/astreum/_lispeum/expression.py +100 -0
  29. astreum-0.2.50/src/astreum/_lispeum/high_evaluation.py +177 -0
  30. astreum-0.2.50/src/astreum/_lispeum/low_evaluation.py +123 -0
  31. astreum-0.2.50/src/astreum/_lispeum/meter.py +18 -0
  32. astreum-0.2.50/src/astreum/_lispeum/parser.py +56 -0
  33. astreum-0.2.50/src/astreum/_lispeum/tokenizer.py +22 -0
  34. astreum-0.2.50/src/astreum/_node.py +163 -0
  35. astreum-0.2.50/src/astreum/_storage/__init__.py +7 -0
  36. astreum-0.2.50/src/astreum/_storage/atom.py +117 -0
  37. astreum-0.2.50/src/astreum/_storage/patricia.py +443 -0
  38. astreum-0.2.50/src/astreum/_storage/setup.py +35 -0
  39. {astreum-0.2.12 → astreum-0.2.50}/src/astreum/crypto/wesolowski.py +2 -2
  40. astreum-0.2.50/src/astreum/models/block.py +441 -0
  41. astreum-0.2.50/src/astreum/models/merkle.py +205 -0
  42. astreum-0.2.50/src/astreum/models/patricia.py +393 -0
  43. astreum-0.2.50/src/astreum/node.py +781 -0
  44. astreum-0.2.50/src/astreum/storage/object.py +68 -0
  45. astreum-0.2.50/src/astreum/storage/setup.py +15 -0
  46. astreum-0.2.50/src/astreum/utils/bytes.py +24 -0
  47. astreum-0.2.50/src/astreum/utils/integer.py +25 -0
  48. astreum-0.2.50/src/astreum/utils/logging.py +219 -0
  49. {astreum-0.2.12 → astreum-0.2.50/src/astreum.egg-info}/PKG-INFO +51 -15
  50. astreum-0.2.50/src/astreum.egg-info/SOURCES.txt +60 -0
  51. astreum-0.2.12/src/astreum/__init__.py +0 -1
  52. astreum-0.2.12/src/astreum/_node/__init__.py +0 -447
  53. astreum-0.2.12/src/astreum/_node/storage/merkle.py +0 -224
  54. astreum-0.2.12/src/astreum/_node/storage/patricia.py +0 -289
  55. astreum-0.2.12/src/astreum/lispeum/__init__.py +0 -2
  56. astreum-0.2.12/src/astreum/lispeum/parser.py +0 -41
  57. astreum-0.2.12/src/astreum/lispeum/tokenizer.py +0 -52
  58. astreum-0.2.12/src/astreum/node.py +0 -1214
  59. astreum-0.2.12/src/astreum/utils/patricia.py +0 -249
  60. astreum-0.2.12/src/astreum.egg-info/SOURCES.txt +0 -26
  61. astreum-0.2.12/tests/test_node_machine.py +0 -56
  62. {astreum-0.2.12 → astreum-0.2.50}/LICENSE +0 -0
  63. {astreum-0.2.12 → astreum-0.2.50}/setup.cfg +0 -0
  64. {astreum-0.2.12/src/astreum/_node/storage → astreum-0.2.50/src/astreum/crypto}/__init__.py +0 -0
  65. {astreum-0.2.12 → astreum-0.2.50}/src/astreum/crypto/ed25519.py +0 -0
  66. {astreum-0.2.12 → astreum-0.2.50}/src/astreum/crypto/quadratic_form.py +0 -0
  67. {astreum-0.2.12 → astreum-0.2.50}/src/astreum/crypto/x25519.py +0 -0
  68. {astreum-0.2.12 → astreum-0.2.50}/src/astreum/format.py +0 -0
  69. {astreum-0.2.12/src/astreum/crypto → astreum-0.2.50/src/astreum/models}/__init__.py +0 -0
  70. {astreum-0.2.12/src/astreum/utils → astreum-0.2.50/src/astreum/storage}/__init__.py +0 -0
  71. {astreum-0.2.12 → astreum-0.2.50}/src/astreum.egg-info/dependency_links.txt +0 -0
  72. {astreum-0.2.12 → astreum-0.2.50}/src/astreum.egg-info/requires.txt +0 -0
  73. {astreum-0.2.12 → astreum-0.2.50}/src/astreum.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: astreum
3
- Version: 0.2.12
3
+ Version: 0.2.50
4
4
  Summary: Python library to interact with the Astreum blockchain and its Lispeum virtual machine.
5
5
  Author-email: "Roy R. O. Okello" <roy@stelar.xyz>
6
6
  Project-URL: Homepage, https://github.com/astreum/lib
@@ -35,6 +35,8 @@ When initializing an `astreum.Node`, pass a dictionary with any of the options b
35
35
  | `validation_secret_key` | hex string | `None` | X25519 private key that lets the node participate in the validation route. Leave unset for a non‑validator node. |
36
36
  | `storage_path` | string | `None` | Directory where objects are persisted. If *None*, the node uses an in‑memory store. |
37
37
  | `storage_get_relay_timeout` | float | `5` | Seconds to wait for an object requested from peers before timing‑out. |
38
+ | `logging_retention` | int | `90` | Number of days to keep rotated log files (daily gzip). |
39
+ | `verbose` | bool | `False` | When **True**, also mirror JSON logs to stdout with a human-readable format. |
38
40
 
39
41
  ### Networking
40
42
 
@@ -76,24 +78,45 @@ node = Node(config)
76
78
  The Lispeum virtual machine (VM) is embedded inside `astreum.Node`. You feed it Lispeum source text, and the node tokenizes, parses, and **evaluates** the resulting AST inside an isolated environment.
77
79
 
78
80
  ```python
79
- from astreum.node import Node
80
- from astreum.machine.tokenizer import tokenize
81
- from astreum.machine.parser import parse
81
+ # Define a named function int.add (stack body) and call it with bytes 1 and 2
82
+
83
+ import uuid
84
+ from astreum import Node, Env, Expr
85
+
86
+ # 1) Spin‑up a stand‑alone VM
87
+ node = Node()
88
+
89
+ # 2) Create an environment (simple manual setup)
90
+ env_id = uuid.uuid4()
91
+ node.environments[env_id] = Env()
92
+
93
+ # 3) Build a function value using a low‑level stack body via `sk`.
94
+ # Body does: $0 $1 add (i.e., a + b)
95
+ low_body = Expr.ListExpr([
96
+ Expr.Symbol("$0"), # a (first arg)
97
+ Expr.Symbol("$1"), # b (second arg)
98
+ Expr.Symbol("add"),
99
+ ])
82
100
 
83
- # 1. Spin‑up a stand‑alone VM (machine‑only node).
84
- node = Node({"machine-only": True})
101
+ fn_body = Expr.ListExpr([
102
+ Expr.Symbol("a"),
103
+ Expr.Symbol("b"),
104
+ Expr.ListExpr([low_body, Expr.Symbol("sk")]),
105
+ ])
85
106
 
86
- # 2. Create an environment.
87
- env_id = node.machine_create_environment()
107
+ params = Expr.ListExpr([Expr.Symbol("a"), Expr.Symbol("b")])
108
+ int_add_fn = Expr.ListExpr([fn_body, params, Expr.Symbol("fn")])
88
109
 
89
- # 3. Convert Lispeum source Expr AST.
90
- source = '(+ 1 (* 2 3))'
91
- expr, _ = parse(tokenize(source))
110
+ # 4) Store under the name "int.add"
111
+ node.env_set(env_id, b"int.add", int_add_fn)
92
112
 
93
- # 4. Evaluate
94
- result = node.machine_expr_eval(env_id=env_id, expr=expr) # -> Expr.Integer(7)
113
+ # 5) Retrieve the function and call it with bytes 1 and 2
114
+ bound = node.env_get(env_id, b"int.add")
115
+ call = Expr.ListExpr([Expr.Byte(1), Expr.Byte(2), bound])
116
+ res = node.high_eval(env_id, call)
95
117
 
96
- print(result.value) # 7
118
+ # sk returns a list of bytes; for 1+2 expect a single byte with value 3
119
+ print([b.value for b in res.elements]) # [3]
97
120
  ```
98
121
 
99
122
  ### Handling errors
@@ -115,9 +138,22 @@ except ParseError as e:
115
138
 
116
139
  ---
117
140
 
141
+
142
+ ## Logging
143
+
144
+ Every `Node` instance wires up structured logging automatically:
145
+
146
+ - Logs land in per-instance files named `node.log` under `%LOCALAPPDATA%\Astreum\lib-py\logs/<instance_id>` on Windows and `$XDG_STATE_HOME` (or `~/.local/state`)/`Astreum/lib-py/logs/<instance_id>` on other platforms. The `<instance_id>` is the first 16 hex characters of a BLAKE3 hash of the caller's file path, so running the node from different entry points keeps their logs isolated.
147
+ - Files rotate at midnight UTC with gzip compression (`node-YYYY-MM-DD.log.gz`) and retain 90 days by default. Override via `config["logging_retention"]`.
148
+ - Each event is a single JSON line containing timestamp, level, logger, message, process/thread info, module/function, and the derived `instance_id`.
149
+ - Set `config["verbose"] = True` to mirror logs to stdout in a human-friendly format like `[2025-04-13-42-59] [info] Starting Astreum Node`.
150
+ - The very first entry emitted is the banner `Starting Astreum Node`, signalling that the logging pipeline is live before other subsystems spin up.
151
+
118
152
  ## Testing
119
153
 
120
154
  ```bash
121
-
155
+ python3 -m venv venv
156
+ source venv/bin/activate
157
+ pip install -e .
122
158
  python3 -m unittest discover -s tests
123
159
  ```
@@ -17,6 +17,8 @@ When initializing an `astreum.Node`, pass a dictionary with any of the options b
17
17
  | `validation_secret_key` | hex string | `None` | X25519 private key that lets the node participate in the validation route. Leave unset for a non‑validator node. |
18
18
  | `storage_path` | string | `None` | Directory where objects are persisted. If *None*, the node uses an in‑memory store. |
19
19
  | `storage_get_relay_timeout` | float | `5` | Seconds to wait for an object requested from peers before timing‑out. |
20
+ | `logging_retention` | int | `90` | Number of days to keep rotated log files (daily gzip). |
21
+ | `verbose` | bool | `False` | When **True**, also mirror JSON logs to stdout with a human-readable format. |
20
22
 
21
23
  ### Networking
22
24
 
@@ -58,24 +60,45 @@ node = Node(config)
58
60
  The Lispeum virtual machine (VM) is embedded inside `astreum.Node`. You feed it Lispeum source text, and the node tokenizes, parses, and **evaluates** the resulting AST inside an isolated environment.
59
61
 
60
62
  ```python
61
- from astreum.node import Node
62
- from astreum.machine.tokenizer import tokenize
63
- from astreum.machine.parser import parse
63
+ # Define a named function int.add (stack body) and call it with bytes 1 and 2
64
+
65
+ import uuid
66
+ from astreum import Node, Env, Expr
67
+
68
+ # 1) Spin‑up a stand‑alone VM
69
+ node = Node()
70
+
71
+ # 2) Create an environment (simple manual setup)
72
+ env_id = uuid.uuid4()
73
+ node.environments[env_id] = Env()
74
+
75
+ # 3) Build a function value using a low‑level stack body via `sk`.
76
+ # Body does: $0 $1 add (i.e., a + b)
77
+ low_body = Expr.ListExpr([
78
+ Expr.Symbol("$0"), # a (first arg)
79
+ Expr.Symbol("$1"), # b (second arg)
80
+ Expr.Symbol("add"),
81
+ ])
64
82
 
65
- # 1. Spin‑up a stand‑alone VM (machine‑only node).
66
- node = Node({"machine-only": True})
83
+ fn_body = Expr.ListExpr([
84
+ Expr.Symbol("a"),
85
+ Expr.Symbol("b"),
86
+ Expr.ListExpr([low_body, Expr.Symbol("sk")]),
87
+ ])
67
88
 
68
- # 2. Create an environment.
69
- env_id = node.machine_create_environment()
89
+ params = Expr.ListExpr([Expr.Symbol("a"), Expr.Symbol("b")])
90
+ int_add_fn = Expr.ListExpr([fn_body, params, Expr.Symbol("fn")])
70
91
 
71
- # 3. Convert Lispeum source Expr AST.
72
- source = '(+ 1 (* 2 3))'
73
- expr, _ = parse(tokenize(source))
92
+ # 4) Store under the name "int.add"
93
+ node.env_set(env_id, b"int.add", int_add_fn)
74
94
 
75
- # 4. Evaluate
76
- result = node.machine_expr_eval(env_id=env_id, expr=expr) # -> Expr.Integer(7)
95
+ # 5) Retrieve the function and call it with bytes 1 and 2
96
+ bound = node.env_get(env_id, b"int.add")
97
+ call = Expr.ListExpr([Expr.Byte(1), Expr.Byte(2), bound])
98
+ res = node.high_eval(env_id, call)
77
99
 
78
- print(result.value) # 7
100
+ # sk returns a list of bytes; for 1+2 expect a single byte with value 3
101
+ print([b.value for b in res.elements]) # [3]
79
102
  ```
80
103
 
81
104
  ### Handling errors
@@ -97,9 +120,22 @@ except ParseError as e:
97
120
 
98
121
  ---
99
122
 
123
+
124
+ ## Logging
125
+
126
+ Every `Node` instance wires up structured logging automatically:
127
+
128
+ - Logs land in per-instance files named `node.log` under `%LOCALAPPDATA%\Astreum\lib-py\logs/<instance_id>` on Windows and `$XDG_STATE_HOME` (or `~/.local/state`)/`Astreum/lib-py/logs/<instance_id>` on other platforms. The `<instance_id>` is the first 16 hex characters of a BLAKE3 hash of the caller's file path, so running the node from different entry points keeps their logs isolated.
129
+ - Files rotate at midnight UTC with gzip compression (`node-YYYY-MM-DD.log.gz`) and retain 90 days by default. Override via `config["logging_retention"]`.
130
+ - Each event is a single JSON line containing timestamp, level, logger, message, process/thread info, module/function, and the derived `instance_id`.
131
+ - Set `config["verbose"] = True` to mirror logs to stdout in a human-friendly format like `[2025-04-13-42-59] [info] Starting Astreum Node`.
132
+ - The very first entry emitted is the banner `Starting Astreum Node`, signalling that the logging pipeline is live before other subsystems spin up.
133
+
100
134
  ## Testing
101
135
 
102
136
  ```bash
103
-
137
+ python3 -m venv venv
138
+ source venv/bin/activate
139
+ pip install -e .
104
140
  python3 -m unittest discover -s tests
105
141
  ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "astreum"
3
- version = "0.2.12"
3
+ version = "0.2.50"
4
4
  authors = [
5
5
  { name="Roy R. O. Okello", email="roy@stelar.xyz" },
6
6
  ]
@@ -0,0 +1,9 @@
1
+ """Lightweight package initializer to avoid circular imports during tests.
2
+
3
+ Exports are intentionally minimal; import submodules directly as needed:
4
+ - Node, Expr, Env, tokenize, parse -> from astreum._node or astreum.lispeum
5
+ - Validation types -> from astreum._validation
6
+ - Storage types -> from astreum._storage
7
+ """
8
+
9
+ __all__: list[str] = []
@@ -0,0 +1,11 @@
1
+ from .message import Message
2
+ from .peer import Peer
3
+ from .route import Route
4
+ from .setup import communication_setup
5
+
6
+ __all__ = [
7
+ "Message",
8
+ "Peer",
9
+ "Route",
10
+ "communication_setup",
11
+ ]
@@ -0,0 +1,101 @@
1
+ from enum import IntEnum
2
+ from typing import Optional
3
+ from cryptography.hazmat.primitives import serialization
4
+ from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PublicKey
5
+
6
+ class MessageTopic(IntEnum):
7
+ PING = 0
8
+ OBJECT_REQUEST = 1
9
+ OBJECT_RESPONSE = 2
10
+ ROUTE_REQUEST = 3
11
+ ROUTE_RESPONSE = 4
12
+ TRANSACTION = 5
13
+ STORAGE_REQUEST = 6
14
+
15
+
16
+ class Message:
17
+ handshake: bool
18
+ sender: Optional[X25519PublicKey]
19
+
20
+ topic: Optional[MessageTopic]
21
+ content: bytes
22
+
23
+ def __init__(
24
+ self,
25
+ *,
26
+ handshake: bool = False,
27
+ sender: Optional[X25519PublicKey] = None,
28
+ topic: Optional[MessageTopic] = None,
29
+ content: bytes = b"",
30
+ body: Optional[bytes] = None,
31
+ ) -> None:
32
+ if body is not None:
33
+ if content and content != b"":
34
+ raise ValueError("specify only one of 'content' or 'body'")
35
+ content = body
36
+
37
+ self.handshake = handshake
38
+ self.sender = sender
39
+ self.topic = topic
40
+ self.content = content or b""
41
+
42
+ if self.handshake:
43
+ if self.sender is None:
44
+ raise ValueError("handshake Message requires a sender public key")
45
+ self.topic = None
46
+ self.content = b""
47
+ else:
48
+ if self.topic is None:
49
+ raise ValueError("non-handshake Message requires a topic")
50
+
51
+ def to_bytes(self):
52
+ if self.handshake:
53
+ # handshake byte (1) + raw public key bytes
54
+ return bytes([1]) + self.sender.public_bytes(
55
+ encoding=serialization.Encoding.Raw,
56
+ format=serialization.PublicFormat.Raw
57
+ )
58
+ else:
59
+ # normal message: 0 + topic + content
60
+ return bytes([0, self.topic.value]) + self.content
61
+
62
+ @classmethod
63
+ def from_bytes(cls, data: bytes) -> "Message":
64
+ if len(data) < 1:
65
+ raise ValueError("Cannot parse Message: no data")
66
+ flag = data[0]
67
+ # create empty instance
68
+ msg = cls.__new__(cls)
69
+
70
+ if flag == 1:
71
+ # handshake message: the rest is the peer’s public key
72
+ key_bytes = data[1:]
73
+ if not key_bytes:
74
+ raise ValueError("Handshake message missing sender public key bytes")
75
+ try:
76
+ sender = X25519PublicKey.from_public_bytes(key_bytes)
77
+ except ValueError:
78
+ raise ValueError("Invalid public key bytes")
79
+ if sender is None:
80
+ raise ValueError("Handshake message missing sender public key")
81
+ msg.handshake = True
82
+ msg.sender = sender
83
+ msg.topic = None
84
+ msg.content = b''
85
+ elif flag == 0:
86
+ # normal message: next byte is topic, rest is content
87
+ if len(data) < 2:
88
+ raise ValueError("Cannot parse Message: missing topic byte")
89
+ topic_val = data[1]
90
+ try:
91
+ topic = MessageTopic(topic_val)
92
+ except ValueError:
93
+ raise ValueError(f"Unknown MessageTopic: {topic_val}")
94
+ msg.handshake = False
95
+ msg.sender = None
96
+ msg.topic = topic
97
+ msg.content = data[2:]
98
+ else:
99
+ raise ValueError(f"Invalid handshake flag: {flag}")
100
+
101
+ return msg
@@ -0,0 +1,23 @@
1
+ from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
2
+ from cryptography.hazmat.primitives import serialization
3
+ from datetime import datetime, timezone
4
+ from typing import Optional, Tuple
5
+
6
+ class Peer:
7
+ shared_key: bytes
8
+ timestamp: datetime
9
+ latest_block: bytes
10
+ address: Optional[Tuple[str, int]]
11
+ public_key: X25519PublicKey
12
+ public_key_bytes: bytes
13
+
14
+ def __init__(self, my_sec_key: X25519PrivateKey, peer_pub_key: X25519PublicKey):
15
+ self.shared_key = my_sec_key.exchange(peer_pub_key)
16
+ self.timestamp = datetime.now(timezone.utc)
17
+ self.latest_block = b""
18
+ self.address = None
19
+ self.public_key = peer_pub_key
20
+ self.public_key_bytes = peer_pub_key.public_bytes(
21
+ encoding=serialization.Encoding.Raw,
22
+ format=serialization.PublicFormat.Raw,
23
+ )
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ class PingFormatError(ValueError):
7
+ """Raised when ping payload bytes are invalid."""
8
+
9
+
10
+ @dataclass
11
+ class Ping:
12
+ is_validator: bool
13
+ latest_block: bytes
14
+
15
+ PAYLOAD_SIZE = 33
16
+
17
+ def __post_init__(self) -> None:
18
+ lb = bytes(self.latest_block or b"")
19
+ if len(lb) != 32:
20
+ raise ValueError("latest_block must be exactly 32 bytes")
21
+ self.latest_block = lb
22
+
23
+ def to_bytes(self) -> bytes:
24
+ return (b"\x01" if self.is_validator else b"\x00") + self.latest_block
25
+
26
+ @classmethod
27
+ def from_bytes(cls, data: bytes) -> "Ping":
28
+ if len(data) != cls.PAYLOAD_SIZE:
29
+ raise PingFormatError("ping payload must be exactly 33 bytes")
30
+ flag = data[0]
31
+ if flag not in (0, 1):
32
+ raise PingFormatError("ping validator flag must be 0 or 1")
33
+ return cls(is_validator=bool(flag), latest_block=data[1:])
@@ -0,0 +1,95 @@
1
+ from typing import Dict, List, Optional, Union
2
+ from cryptography.hazmat.primitives import serialization
3
+ from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PublicKey
4
+ from .peer import Peer
5
+
6
+ PeerKey = Union[X25519PublicKey, bytes, bytearray]
7
+
8
+
9
+ class Route:
10
+ def __init__(self, relay_public_key: X25519PublicKey, bucket_size: int = 16):
11
+ self.relay_public_key_bytes = relay_public_key.public_bytes(
12
+ encoding=serialization.Encoding.Raw,
13
+ format=serialization.PublicFormat.Raw,
14
+ )
15
+ self.bucket_size = bucket_size
16
+ self.buckets: Dict[int, List[bytes]] = {
17
+ i: [] for i in range(len(self.relay_public_key_bytes) * 8)
18
+ }
19
+ self.peers: Dict[bytes, Peer] = {}
20
+
21
+ @staticmethod
22
+ def _matching_leading_bits(a: bytes, b: bytes) -> int:
23
+ for byte_index, (ba, bb) in enumerate(zip(a, b)):
24
+ diff = ba ^ bb
25
+ if diff:
26
+ return byte_index * 8 + (8 - diff.bit_length())
27
+ return len(a) * 8
28
+
29
+ def _normalize_peer_key(self, peer_public_key: PeerKey) -> bytes:
30
+ if isinstance(peer_public_key, X25519PublicKey):
31
+ return peer_public_key.public_bytes(
32
+ encoding=serialization.Encoding.Raw,
33
+ format=serialization.PublicFormat.Raw,
34
+ )
35
+ if isinstance(peer_public_key, (bytes, bytearray)):
36
+ key_bytes = bytes(peer_public_key)
37
+ if len(key_bytes) != len(self.relay_public_key_bytes):
38
+ raise ValueError("peer key must be raw 32-byte public key")
39
+ return key_bytes
40
+ raise TypeError("peer_public_key must be raw bytes or X25519PublicKey")
41
+
42
+ @staticmethod
43
+ def _xor_distance(a: bytes, b: bytes) -> int:
44
+ if len(a) != len(b):
45
+ raise ValueError("xor distance requires equal-length operands")
46
+ return int.from_bytes(bytes(x ^ y for x, y in zip(a, b)), "big", signed=False)
47
+
48
+ def add_peer(self, peer_public_key: PeerKey, peer: Optional[Peer] = None):
49
+ peer_public_key_bytes = self._normalize_peer_key(peer_public_key)
50
+ bucket_idx = self._matching_leading_bits(self.relay_public_key_bytes, peer_public_key_bytes)
51
+ if len(self.buckets[bucket_idx]) < self.bucket_size:
52
+ bucket = self.buckets[bucket_idx]
53
+ if peer_public_key_bytes not in bucket:
54
+ bucket.append(peer_public_key_bytes)
55
+ if peer is not None:
56
+ self.peers[peer_public_key_bytes] = peer
57
+
58
+ def remove_peer(self, peer_public_key: PeerKey):
59
+ peer_public_key_bytes = self._normalize_peer_key(peer_public_key)
60
+ bucket_idx = self._matching_leading_bits(self.relay_public_key_bytes, peer_public_key_bytes)
61
+ bucket = self.buckets.get(bucket_idx)
62
+ if not bucket:
63
+ return
64
+ try:
65
+ bucket.remove(peer_public_key_bytes)
66
+ except ValueError:
67
+ pass
68
+ self.peers.pop(peer_public_key_bytes, None)
69
+
70
+ def closest_peer_for_hash(self, target_hash: bytes) -> Optional[Peer]:
71
+ """Return the peer with the minimal XOR distance to ``target_hash``."""
72
+ if not isinstance(target_hash, (bytes, bytearray)):
73
+ raise TypeError("target_hash must be bytes-like")
74
+
75
+ target = bytes(target_hash)
76
+ if len(target) != len(self.relay_public_key_bytes):
77
+ raise ValueError("target_hash must match peer key length (32 bytes)")
78
+
79
+ closest_key: Optional[bytes] = None
80
+ closest_distance: Optional[int] = None
81
+
82
+ for bucket in self.buckets.values():
83
+ for peer_key in bucket:
84
+ try:
85
+ distance = self._xor_distance(target, peer_key)
86
+ except ValueError:
87
+ continue
88
+ if closest_distance is None or distance < closest_distance:
89
+ closest_distance = distance
90
+ closest_key = peer_key
91
+
92
+ if closest_key is None:
93
+ return None
94
+ peer = self.peers.get(closest_key)
95
+ return peer