astreum 0.2.41__py3-none-any.whl → 0.2.61__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.
@@ -10,6 +10,7 @@ class MessageTopic(IntEnum):
10
10
  ROUTE_REQUEST = 3
11
11
  ROUTE_RESPONSE = 4
12
12
  TRANSACTION = 5
13
+ STORAGE_REQUEST = 6
13
14
 
14
15
 
15
16
  class Message:
@@ -1,11 +1,23 @@
1
- from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
2
- from datetime import datetime, timezone
3
-
4
- class Peer:
5
- shared_key: bytes
6
- timestamp: datetime
7
- latest_block: bytes
8
-
9
- def __init__(self, my_sec_key: X25519PrivateKey, peer_pub_key: X25519PublicKey):
10
- self.shared_key = my_sec_key.exchange(peer_pub_key)
11
- self.timestamp = datetime.now(timezone.utc)
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
+ )
@@ -1,6 +1,7 @@
1
- from typing import Dict, List, Union
1
+ from typing import Dict, List, Optional, Union
2
2
  from cryptography.hazmat.primitives import serialization
3
3
  from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PublicKey
4
+ from .peer import Peer
4
5
 
5
6
  PeerKey = Union[X25519PublicKey, bytes, bytearray]
6
7
 
@@ -15,7 +16,7 @@ class Route:
15
16
  self.buckets: Dict[int, List[bytes]] = {
16
17
  i: [] for i in range(len(self.relay_public_key_bytes) * 8)
17
18
  }
18
- self.peers = {}
19
+ self.peers: Dict[bytes, Peer] = {}
19
20
 
20
21
  @staticmethod
21
22
  def _matching_leading_bits(a: bytes, b: bytes) -> int:
@@ -38,13 +39,21 @@ class Route:
38
39
  return key_bytes
39
40
  raise TypeError("peer_public_key must be raw bytes or X25519PublicKey")
40
41
 
41
- def add_peer(self, peer_public_key: PeerKey):
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):
42
49
  peer_public_key_bytes = self._normalize_peer_key(peer_public_key)
43
50
  bucket_idx = self._matching_leading_bits(self.relay_public_key_bytes, peer_public_key_bytes)
44
51
  if len(self.buckets[bucket_idx]) < self.bucket_size:
45
52
  bucket = self.buckets[bucket_idx]
46
53
  if peer_public_key_bytes not in bucket:
47
54
  bucket.append(peer_public_key_bytes)
55
+ if peer is not None:
56
+ self.peers[peer_public_key_bytes] = peer
48
57
 
49
58
  def remove_peer(self, peer_public_key: PeerKey):
50
59
  peer_public_key_bytes = self._normalize_peer_key(peer_public_key)
@@ -56,3 +65,31 @@ class Route:
56
65
  bucket.remove(peer_public_key_bytes)
57
66
  except ValueError:
58
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
@@ -95,9 +95,10 @@ def process_incoming_messages(node: "Node") -> None:
95
95
  peer = Peer(node.relay_secret_key, sender_key)
96
96
  except Exception:
97
97
  continue
98
-
98
+ peer.address = address_key
99
+
99
100
  node.peers[sender_public_key_bytes] = peer
100
- node.peer_route.add_peer(sender_public_key_bytes)
101
+ node.peer_route.add_peer(sender_public_key_bytes, peer)
101
102
 
102
103
  response = Message(handshake=True, sender=node.relay_public_key)
103
104
  node.outgoing_queue.put((response.to_bytes(), address_key))
@@ -105,17 +106,25 @@ def process_incoming_messages(node: "Node") -> None:
105
106
 
106
107
  elif old_key_bytes == sender_public_key_bytes:
107
108
  # existing mapping with same key -> nothing to change
108
- pass
109
+ peer = node.peers.get(sender_public_key_bytes)
110
+ if peer is not None:
111
+ peer.address = address_key
109
112
 
110
113
  else:
111
114
  # address reused with a different key -> replace peer
112
115
  node.peers.pop(old_key_bytes, None)
116
+ try:
117
+ node.peer_route.remove_peer(old_key_bytes)
118
+ except Exception:
119
+ pass
113
120
  try:
114
121
  peer = Peer(node.relay_secret_key, sender_key)
115
122
  except Exception:
116
123
  continue
117
-
124
+ peer.address = address_key
125
+
118
126
  node.peers[sender_public_key_bytes] = peer
127
+ node.peer_route.add_peer(sender_public_key_bytes, peer)
119
128
 
120
129
  match message.topic:
121
130
  case MessageTopic.PING:
@@ -164,6 +173,65 @@ def process_incoming_messages(node: "Node") -> None:
164
173
  if node.validation_secret_key is None:
165
174
  continue
166
175
  node._validation_transaction_queue.put(message.content)
176
+
177
+ case MessageTopic.STORAGE_REQUEST:
178
+ payload = message.content
179
+ if len(payload) < 32:
180
+ continue
181
+
182
+ atom_id = payload[:32]
183
+ provider_bytes = payload[32:]
184
+ if not provider_bytes:
185
+ continue
186
+
187
+ try:
188
+ provider_str = provider_bytes.decode("utf-8")
189
+ except UnicodeDecodeError:
190
+ continue
191
+
192
+ try:
193
+ host, port = addr[0], int(addr[1])
194
+ except Exception:
195
+ continue
196
+ address_key = (host, port)
197
+ sender_key_bytes = node.addresses.get(address_key)
198
+ if sender_key_bytes is None:
199
+ continue
200
+
201
+ try:
202
+ local_key_bytes = node.relay_public_key.public_bytes(
203
+ encoding=serialization.Encoding.Raw,
204
+ format=serialization.PublicFormat.Raw,
205
+ )
206
+ except Exception:
207
+ continue
208
+
209
+ def xor_distance(target: bytes, key: bytes) -> int:
210
+ return int.from_bytes(
211
+ bytes(a ^ b for a, b in zip(target, key)),
212
+ byteorder="big",
213
+ signed=False,
214
+ )
215
+
216
+ self_distance = xor_distance(atom_id, local_key_bytes)
217
+
218
+ try:
219
+ closest_peer = node.peer_route.closest_peer_for_hash(atom_id)
220
+ except Exception:
221
+ closest_peer = None
222
+
223
+ if (
224
+ closest_peer is not None
225
+ and closest_peer.public_key_bytes != sender_key_bytes
226
+ ):
227
+ closest_distance = xor_distance(atom_id, closest_peer.public_key_bytes)
228
+ if closest_distance < self_distance:
229
+ target_addr = closest_peer.address
230
+ if target_addr is not None and target_addr != addr:
231
+ node.outgoing_queue.put((message.to_bytes(), target_addr))
232
+ continue
233
+
234
+ node.storage_index[atom_id] = provider_str.strip()
167
235
 
168
236
  case _:
169
237
  continue
@@ -1,170 +1,95 @@
1
- from __future__ import annotations
2
-
1
+ from __future__ import annotations
2
+
3
3
  from dataclasses import dataclass, field
4
- from typing import Any, Callable, List, Optional, Tuple
4
+ from typing import Any, List, Optional, Tuple
5
5
 
6
6
  from .._storage.atom import Atom, ZERO32
7
-
8
-
9
- def _int_to_be_bytes(value: int) -> bytes:
10
- value = int(value)
11
- if value < 0:
12
- raise ValueError("account integers must be non-negative")
13
- if value == 0:
14
- return b"\x00"
15
- size = (value.bit_length() + 7) // 8
16
- return value.to_bytes(size, "big")
17
-
18
-
19
- def _be_bytes_to_int(data: Optional[bytes]) -> int:
20
- if not data:
21
- return 0
22
- return int.from_bytes(data, "big")
23
-
24
-
25
- def _make_list(child_ids: List[bytes]) -> Tuple[bytes, List[Atom]]:
26
- next_hash = ZERO32
27
- elements: List[Atom] = []
28
- for child_id in reversed(child_ids):
29
- elem = Atom.from_data(data=child_id, next_hash=next_hash)
30
- next_hash = elem.object_id()
31
- elements.append(elem)
32
- elements.reverse()
33
- value_atom = Atom.from_data(
34
- data=len(child_ids).to_bytes(8, "little"),
35
- next_hash=next_hash,
36
- )
37
- type_atom = Atom.from_data(data=b"list", next_hash=value_atom.object_id())
38
- atoms = elements + [value_atom, type_atom]
39
- return type_atom.object_id(), atoms
40
-
41
-
42
- def _resolve_storage_get(source: Any) -> Callable[[bytes], Optional[Atom]]:
43
- if callable(source):
44
- return source
45
- getter = getattr(source, "_local_get", None)
46
- if callable(getter):
47
- return getter
48
- raise TypeError("Account.from_atom needs a callable storage getter or node with '_local_get'")
49
-
50
-
51
- def _read_list_entries(
52
- storage_get: Callable[[bytes], Optional[Atom]],
53
- start: bytes,
54
- ) -> List[bytes]:
55
- entries: List[bytes] = []
56
- current = start if start and start != ZERO32 else b""
57
- while current:
58
- elem = storage_get(current)
59
- if elem is None:
60
- break
61
- entries.append(elem.data)
62
- nxt = elem.next
63
- current = nxt if nxt and nxt != ZERO32 else b""
64
- return entries
7
+ from .._storage.patricia import PatriciaTrie
8
+ from ..utils.integer import bytes_to_int, int_to_bytes
65
9
 
66
10
 
67
11
  @dataclass
68
12
  class Account:
69
- _balance: int
70
- _data: bytes
71
- _nonce: int
13
+ balance: int
14
+ code: bytes
15
+ counter: int
16
+ data_hash: bytes
17
+ data: PatriciaTrie
72
18
  hash: bytes = ZERO32
19
+ body_hash: bytes = ZERO32
73
20
  atoms: List[Atom] = field(default_factory=list)
74
21
 
75
- @staticmethod
76
- def _encode(balance: int, data: bytes, nonce: int) -> Tuple[bytes, List[Atom]]:
77
- balance_atom = Atom.from_data(data=_int_to_be_bytes(balance))
78
- data_atom = Atom.from_data(data=bytes(data))
79
- nonce_atom = Atom.from_data(data=_int_to_be_bytes(nonce))
80
-
81
- field_atoms = [balance_atom, data_atom, nonce_atom]
82
- field_ids = [a.object_id() for a in field_atoms]
83
-
84
- body_id, body_atoms = _make_list(field_ids)
85
- type_atom = Atom.from_data(data=b"account", next_hash=body_id)
86
- top_id, top_atoms = _make_list([type_atom.object_id(), body_id])
87
-
88
- atoms = field_atoms + body_atoms + [type_atom] + top_atoms
89
- return top_id, atoms
90
-
91
22
  @classmethod
92
- def create(cls, balance: int, data: bytes, nonce: int) -> "Account":
93
- account_hash, atoms = cls._encode(balance, data, nonce)
94
- return cls(
95
- _balance=int(balance),
96
- _data=bytes(data),
97
- _nonce=int(nonce),
98
- hash=account_hash,
99
- atoms=atoms,
23
+ def create(cls, balance: int = 0, data_hash: bytes = ZERO32, code: bytes = ZERO32, counter: int = 0) -> "Account":
24
+ account = cls(
25
+ balance=int(balance),
26
+ code=bytes(code),
27
+ counter=int(counter),
28
+ data_hash=bytes(data_hash),
29
+ data=PatriciaTrie(root_hash=bytes(data_hash)),
100
30
  )
31
+ account.to_atom()
32
+ return account
101
33
 
102
34
  @classmethod
103
- def from_atom(cls, source: Any, account_id: bytes) -> "Account":
104
- storage_get = _resolve_storage_get(source)
105
-
106
- outer_list = storage_get(account_id)
107
- if outer_list is None or outer_list.data != b"list":
108
- raise ValueError("not an account (outer list missing)")
109
-
110
- outer_value = storage_get(outer_list.next)
111
- if outer_value is None:
112
- raise ValueError("malformed account (outer value missing)")
35
+ def from_atom(cls, node: Any, account_id: bytes) -> "Account":
36
+ storage_get = node.storage_get
113
37
 
114
- entries = _read_list_entries(storage_get, outer_value.next)
115
- if len(entries) < 2:
116
- raise ValueError("malformed account (type/body missing)")
117
-
118
- type_atom_id, body_id = entries[0], entries[1]
119
- type_atom = storage_get(type_atom_id)
38
+ type_atom = storage_get(account_id)
120
39
  if type_atom is None or type_atom.data != b"account":
121
40
  raise ValueError("not an account (type mismatch)")
122
41
 
123
- body_list = storage_get(body_id)
124
- if body_list is None or body_list.data != b"list":
125
- raise ValueError("malformed account body (type)")
42
+ def _read_atom(atom_id: Optional[bytes]) -> Optional[Atom]:
43
+ if not atom_id or atom_id == ZERO32:
44
+ return None
45
+ return storage_get(atom_id)
126
46
 
127
- body_value = storage_get(body_list.next)
128
- if body_value is None:
129
- raise ValueError("malformed account body (value)")
47
+ balance_atom = _read_atom(type_atom.next)
48
+ if balance_atom is None:
49
+ raise ValueError("malformed account (balance missing)")
130
50
 
131
- field_ids = _read_list_entries(storage_get, body_value.next)
132
- if len(field_ids) < 3:
133
- field_ids.extend([ZERO32] * (3 - len(field_ids)))
51
+ code_atom = _read_atom(balance_atom.next)
52
+ if code_atom is None:
53
+ raise ValueError("malformed account (code missing)")
134
54
 
135
- def _read_field(field_id: bytes) -> bytes:
136
- if not field_id or field_id == ZERO32:
137
- return b""
138
- atom = storage_get(field_id)
139
- return atom.data if atom is not None else b""
55
+ counter_atom = _read_atom(code_atom.next)
56
+ if counter_atom is None:
57
+ raise ValueError("malformed account (counter missing)")
140
58
 
141
- balance_bytes = _read_field(field_ids[0])
142
- data_bytes = _read_field(field_ids[1])
143
- nonce_bytes = _read_field(field_ids[2])
59
+ data_atom = _read_atom(counter_atom.next)
60
+ if data_atom is None:
61
+ raise ValueError("malformed account (data missing)")
144
62
 
145
63
  account = cls.create(
146
- balance=_be_bytes_to_int(balance_bytes),
147
- data=data_bytes,
148
- nonce=_be_bytes_to_int(nonce_bytes),
64
+ balance=bytes_to_int(balance_atom.data),
65
+ data_hash=data_atom.data,
66
+ counter=bytes_to_int(counter_atom.data),
67
+ code=code_atom.data,
149
68
  )
150
69
  if account.hash != account_id:
151
70
  raise ValueError("account hash mismatch while decoding")
152
71
  return account
153
72
 
154
- def balance(self) -> int:
155
- return self._balance
156
-
157
- def data(self) -> bytes:
158
- return self._data
159
-
160
- def nonce(self) -> int:
161
- return self._nonce
162
-
163
- def body_hash(self) -> bytes:
164
- return self.hash
165
-
166
73
  def to_atom(self) -> Tuple[bytes, List[Atom]]:
167
- account_hash, atoms = self._encode(self._balance, self._data, self._nonce)
74
+ # Build a single forward chain: account -> balance -> code -> counter -> data.
75
+ data_atom = Atom.from_data(data=bytes(self.data_hash))
76
+ counter_atom = Atom.from_data(
77
+ data=int_to_bytes(self.counter),
78
+ next_hash=data_atom.object_id(),
79
+ )
80
+ code_atom = Atom.from_data(
81
+ data=bytes(self.code),
82
+ next_hash=counter_atom.object_id(),
83
+ )
84
+ balance_atom = Atom.from_data(
85
+ data=int_to_bytes(self.balance),
86
+ next_hash=code_atom.object_id(),
87
+ )
88
+ type_atom = Atom.from_data(data=b"account", next_hash=balance_atom.object_id())
89
+
90
+ atoms = [data_atom, counter_atom, code_atom, balance_atom, type_atom]
91
+ account_hash = type_atom.object_id()
168
92
  self.hash = account_hash
93
+ self.body_hash = account_hash
169
94
  self.atoms = atoms
170
95
  return account_hash, list(atoms)
@@ -1,9 +1,7 @@
1
- from __future__ import annotations
2
1
  from __future__ import annotations
3
2
 
4
- from typing import Any, Dict, Iterable, Optional, Tuple
3
+ from typing import Any, Dict, Optional
5
4
 
6
- from .._storage.atom import Atom
7
5
  from .._storage.patricia import PatriciaTrie
8
6
  from .account import Account
9
7
 
@@ -15,53 +13,26 @@ class Accounts:
15
13
  ) -> None:
16
14
  self._trie = PatriciaTrie(root_hash=root_hash)
17
15
  self._cache: Dict[bytes, Account] = {}
18
- self._staged: Dict[bytes, Account] = {}
19
- self._staged_hashes: Dict[bytes, bytes] = {}
20
- self._staged_atoms: Dict[bytes, Iterable[Atom]] = {}
21
- self._node: Optional[Any] = None
22
16
 
23
17
  @property
24
18
  def root_hash(self) -> Optional[bytes]:
25
19
  return self._trie.root_hash
26
20
 
27
- def _resolve_node(self, node: Optional[Any]) -> Any:
28
- if node is not None:
29
- if self._node is None:
30
- self._node = node
31
- return node
32
- if self._node is None:
33
- raise ValueError("Accounts requires a node reference for trie access")
34
- return self._node
35
-
36
- def get_account(self, address: bytes, *, node: Optional[Any] = None) -> Optional[Account]:
37
- if address in self._staged:
38
- return self._staged[address]
39
-
21
+ def get_account(self, address: bytes, node: Optional[Any] = None) -> Optional[Account]:
40
22
  cached = self._cache.get(address)
41
23
  if cached is not None:
42
24
  return cached
43
25
 
44
- storage_node = self._resolve_node(node)
45
- account_id: Optional[bytes] = self._trie.get(storage_node, address)
26
+ if node is None:
27
+ raise ValueError("Accounts requires a node reference for trie access")
28
+
29
+ account_id: Optional[bytes] = self._trie.get(node, address)
46
30
  if account_id is None:
47
31
  return None
48
32
 
49
- account = Account.from_atom(storage_node, account_id)
33
+ account = Account.from_atom(node, account_id)
50
34
  self._cache[address] = account
51
35
  return account
52
36
 
53
37
  def set_account(self, address: bytes, account: Account) -> None:
54
- account_hash, atoms = account.to_atom()
55
- self._staged[address] = account
56
- self._staged_hashes[address] = account_hash
57
- self._staged_atoms[address] = tuple(atoms)
58
38
  self._cache[address] = account
59
-
60
- def staged_items(self) -> Iterable[Tuple[bytes, Account]]:
61
- return self._staged.items()
62
-
63
- def staged_hashes(self) -> Dict[bytes, bytes]:
64
- return dict(self._staged_hashes)
65
-
66
- def staged_atoms(self) -> Dict[bytes, Iterable[Atom]]:
67
- return {addr: tuple(atoms) for addr, atoms in self._staged_atoms.items()}