astreum 0.1.7__py3-none-any.whl → 0.1.9__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.

Potentially problematic release.


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

astreum/node/__init__.py CHANGED
@@ -1,55 +1,49 @@
1
1
  import os
2
2
  import hashlib
3
3
  import time
4
- from typing import Tuple
4
+ from typing import Tuple, Optional
5
+ import json
5
6
 
6
7
  from .relay import Relay, Topic
7
8
  from .relay.peer import Peer
8
- from .storage import Storage
9
- from .route_table import RouteTable
9
+ from .models import Storage, Block, Transaction
10
10
  from .machine import AstreumMachine
11
11
  from .utils import encode, decode
12
- from .models import Block, Transaction
13
12
  from astreum.lispeum.storage import store_expr, get_expr_from_storage
14
13
 
15
14
  class Node:
16
15
  def __init__(self, config: dict):
17
16
  self.config = config
18
- self.node_id = config.get('node_id', os.urandom(32)) # Default to random ID if not provided
19
17
  self.relay = Relay(config)
18
+ # Get the node_id from relay instead of generating our own
19
+ self.node_id = self.relay.node_id
20
20
  self.storage = Storage(config)
21
+ self.storage.node = self # Set the storage node reference to self
21
22
 
22
23
  # Latest block of the chain this node is following
23
24
  self.latest_block = None
24
25
  self.followed_chain_id = config.get('followed_chain_id', None)
25
26
 
26
- # Candidate chains that might be adopted
27
- self.candidate_chains = {} # chain_id -> {'latest_block': block, 'timestamp': time.time()}
28
-
29
- # Initialize route table with our node ID
30
- self.route_table = RouteTable(config, self.node_id)
31
-
32
- # Initialize machine after storage so it can use it
33
- # Pass self to machine so it can access the storage
27
+ # Initialize machine
34
28
  self.machine = AstreumMachine(node=self)
35
29
 
36
30
  # Register message handlers
37
- self._register_message_handlers()
31
+ self.relay.message_handlers[Topic.PEER_ROUTE] = self._handle_peer_route
32
+ self.relay.message_handlers[Topic.PING] = self._handle_ping
33
+ self.relay.message_handlers[Topic.PONG] = self._handle_pong
34
+ self.relay.message_handlers[Topic.OBJECT_REQUEST] = self._handle_object_request
35
+ self.relay.message_handlers[Topic.OBJECT_RESPONSE] = self._handle_object_response
36
+ self.relay.message_handlers[Topic.ROUTE_REQUEST] = self._handle_route_request
37
+ self.relay.message_handlers[Topic.ROUTE] = self._handle_route
38
+ self.relay.message_handlers[Topic.LATEST_BLOCK_REQUEST] = self._handle_latest_block_request
39
+ self.relay.message_handlers[Topic.LATEST_BLOCK] = self._handle_latest_block
40
+ self.relay.message_handlers[Topic.TRANSACTION] = self._handle_transaction
38
41
 
39
42
  # Initialize latest block from storage if available
40
43
  self._initialize_latest_block()
41
44
 
42
- def _register_message_handlers(self):
43
- """Register handlers for different message topics."""
44
- self.relay.register_message_handler(Topic.PING, self._handle_ping)
45
- self.relay.register_message_handler(Topic.PONG, self._handle_pong)
46
- self.relay.register_message_handler(Topic.OBJECT_REQUEST, self._handle_object_request)
47
- self.relay.register_message_handler(Topic.OBJECT, self._handle_object)
48
- self.relay.register_message_handler(Topic.ROUTE_REQUEST, self._handle_route_request)
49
- self.relay.register_message_handler(Topic.ROUTE, self._handle_route)
50
- self.relay.register_message_handler(Topic.LATEST_BLOCK_REQUEST, self._handle_latest_block_request)
51
- self.relay.register_message_handler(Topic.LATEST_BLOCK, self._handle_latest_block)
52
- self.relay.register_message_handler(Topic.TRANSACTION, self._handle_transaction)
45
+ # Candidate chains that might be adopted
46
+ self.candidate_chains = {} # chain_id -> {'latest_block': block, 'timestamp': time.time()}
53
47
 
54
48
  def _handle_ping(self, body: bytes, addr: Tuple[str, int], envelope):
55
49
  """
@@ -70,7 +64,7 @@ class Node:
70
64
  difficulty = int.from_bytes(difficulty_bytes, byteorder='big')
71
65
 
72
66
  # Store peer information in routing table
73
- peer = self.route_table.update_peer(addr, public_key, difficulty)
67
+ peer = self.relay.add_peer(addr, public_key, difficulty)
74
68
 
75
69
  # Process the routes the sender is participating in
76
70
  if routes_data:
@@ -104,7 +98,7 @@ class Node:
104
98
  difficulty = int.from_bytes(difficulty_bytes, byteorder='big')
105
99
 
106
100
  # Update peer information in routing table
107
- peer = self.route_table.update_peer(addr, public_key, difficulty)
101
+ peer = self.relay.add_peer(addr, public_key, difficulty)
108
102
 
109
103
  # Process the routes the sender is participating in
110
104
  if routes_data:
@@ -116,21 +110,65 @@ class Node:
116
110
 
117
111
  def _handle_object_request(self, body: bytes, addr: Tuple[str, int], envelope):
118
112
  """
119
- Handle request for an object by its hash.
120
- Check storage and return if available, otherwise ignore.
113
+ Handle an object request from a peer.
114
+
115
+ Args:
116
+ body: Message body containing the object hash
117
+ addr: Address of the requesting peer
118
+ envelope: Full message envelope
121
119
  """
122
120
  try:
123
- # The body is the hash of the requested object
124
- object_hash = body
125
- object_data = self.storage.get(object_hash)
121
+ # Decode the request
122
+ request = json.loads(body.decode('utf-8'))
123
+ object_hash = bytes.fromhex(request.get('hash'))
124
+
125
+ # Check if we have the requested object
126
+ if not self.storage.contains(object_hash):
127
+ # We don't have the object, ignore the request
128
+ return
129
+
130
+ # Get the object data
131
+ object_data = self.storage._local_get(object_hash)
132
+ if not object_data:
133
+ return
134
+
135
+ # Create a response message
136
+ response = {
137
+ 'hash': object_hash.hex(),
138
+ 'data': object_data.hex()
139
+ }
140
+
141
+ # Send the response
142
+ self.relay.send_message_to_addr(
143
+ addr,
144
+ Topic.OBJECT_RESPONSE,
145
+ json.dumps(response).encode('utf-8')
146
+ )
126
147
 
127
- if object_data:
128
- # Object found, send it back
129
- self.relay.send_message(object_data, Topic.OBJECT, addr)
130
- # If object not found, simply ignore the request
131
148
  except Exception as e:
132
149
  print(f"Error handling object request: {e}")
133
150
 
151
+ def _handle_object_response(self, body: bytes, addr: Tuple[str, int], envelope):
152
+ """
153
+ Handle an object response from a peer.
154
+
155
+ Args:
156
+ body: Message body containing the object hash and data
157
+ addr: Address of the responding peer
158
+ envelope: Full message envelope
159
+ """
160
+ try:
161
+ # Decode the response
162
+ response = json.loads(body.decode('utf-8'))
163
+ object_hash = bytes.fromhex(response.get('hash'))
164
+ object_data = bytes.fromhex(response.get('data'))
165
+
166
+ # Store the object
167
+ self.storage.put(object_hash, object_data)
168
+
169
+ except Exception as e:
170
+ print(f"Error handling object response: {e}")
171
+
134
172
  def _handle_object(self, body: bytes, addr: Tuple[str, int], envelope):
135
173
  """
136
174
  Handle receipt of an object.
@@ -147,6 +185,68 @@ class Node:
147
185
  except Exception as e:
148
186
  print(f"Error handling object: {e}")
149
187
 
188
+ def request_object(self, object_hash: bytes, max_attempts: int = 3) -> Optional[bytes]:
189
+ """
190
+ Request an object from the network by its hash.
191
+
192
+ This method sends an object request to peers closest to the object hash
193
+ and waits for a response until timeout.
194
+
195
+ Args:
196
+ object_hash: The hash of the object to request
197
+ max_attempts: Maximum number of request attempts
198
+
199
+ Returns:
200
+ The object data if found, None otherwise
201
+ """
202
+ # First check if we already have the object
203
+ if self.storage.contains(object_hash):
204
+ return self.storage._local_get(object_hash)
205
+
206
+ # Find the bucket containing the peers closest to the object's hash
207
+ closest_peers = self.relay.get_closest_peers(object_hash, count=3)
208
+ if not closest_peers:
209
+ return None
210
+
211
+ # Create a message to request the object
212
+ topic = Topic.OBJECT_REQUEST
213
+ object_request_msg = {
214
+ 'hash': object_hash.hex()
215
+ }
216
+
217
+ # Track which peers we've already tried
218
+ attempted_peers = set()
219
+
220
+ # We'll try up to max_attempts times
221
+ for _ in range(max_attempts):
222
+ # Find peers we haven't tried yet
223
+ untried_peers = [p for p in closest_peers if p.id not in attempted_peers]
224
+ if not untried_peers:
225
+ break
226
+
227
+ # Send the request to all untried peers
228
+ request_sent = False
229
+ for peer in untried_peers:
230
+ try:
231
+ self.relay.send_message_to_peer(peer, topic, object_request_msg)
232
+ attempted_peers.add(peer.id)
233
+ request_sent = True
234
+ except Exception as e:
235
+ print(f"Failed to send object request to peer {peer.id.hex()}: {e}")
236
+
237
+ if not request_sent:
238
+ break
239
+
240
+ # Short wait to allow for response
241
+ time.sleep(0.5)
242
+
243
+ # Check if any of the requests succeeded
244
+ if self.storage.contains(object_hash):
245
+ return self.storage._local_get(object_hash)
246
+
247
+ # If we get here, we couldn't get the object
248
+ return None
249
+
150
250
  def _handle_route_request(self, body: bytes, addr: Tuple[str, int], envelope):
151
251
  """
152
252
  Handle request for routing information.
@@ -157,8 +257,8 @@ class Node:
157
257
  route_peers = []
158
258
 
159
259
  # Get one peer from each bucket
160
- for bucket_index in range(self.route_table.num_buckets):
161
- peers = self.route_table.get_bucket_peers(bucket_index)
260
+ for bucket_index in range(self.relay.num_buckets):
261
+ peers = self.relay.get_bucket_peers(bucket_index)
162
262
  if peers and len(peers) > 0:
163
263
  # Add one peer from this bucket
164
264
  route_peers.append(peers[0])
@@ -206,7 +306,7 @@ class Node:
206
306
 
207
307
  # Ping this peer if it's not already in our routing table
208
308
  # and it's not our own address
209
- if (not self.route_table.has_peer(peer_address) and
309
+ if (not self.relay.has_peer(peer_address) and
210
310
  peer_address != self.relay.get_address()):
211
311
  # Create ping message with our info and routes
212
312
  # Encode our peer and validation routes
@@ -339,21 +439,10 @@ class Node:
339
439
  print(f"Error handling transaction: {e}")
340
440
 
341
441
  def _initialize_latest_block(self):
342
- """Initialize the latest block from storage if available."""
343
- try:
344
- if self.followed_chain_id:
345
- # Get the latest block for the chain we're following
346
- self.latest_block = self.machine.get_latest_block(self.followed_chain_id)
347
- else:
348
- # If no specific chain is set to follow, get the latest block from the default chain
349
- self.latest_block = self.machine.get_latest_block()
350
-
351
- # If we have a latest block, set the followed chain ID
352
- if self.latest_block:
353
- self.followed_chain_id = self.latest_block.chain_id
354
- except Exception as e:
355
- print(f"Error initializing latest block: {e}")
356
-
442
+ """Initialize latest block from storage if available."""
443
+ # Implementation would load the latest block from storage
444
+ pass
445
+
357
446
  def set_followed_chain(self, chain_id):
358
447
  """
359
448
  Set the chain that this node follows.
astreum/node/models.py CHANGED
@@ -1,12 +1,15 @@
1
1
  import socket
2
2
  from pathlib import Path
3
- from typing import Optional, Tuple
3
+ from typing import Optional, Tuple, Dict
4
4
  from astreum.machine import AstreumMachine
5
5
  from .relay import Relay
6
6
  from .relay.message import Topic
7
7
  from .relay.route import RouteTable
8
8
  from .relay.peer import Peer
9
9
  import os
10
+ import struct
11
+ import threading
12
+ import time
10
13
 
11
14
  class Storage:
12
15
  def __init__(self, config: dict):
@@ -14,6 +17,13 @@ class Storage:
14
17
  self.current_space = 0
15
18
  self.storage_path = Path(config.get('storage_path', 'storage'))
16
19
  self.storage_path.mkdir(parents=True, exist_ok=True)
20
+ self.max_object_recursion = config.get('max_object_recursion', 50)
21
+ self.network_request_timeout = config.get('network_request_timeout', 5.0) # Default 5 second timeout
22
+ self.node = None # Will be set by the Node after initialization
23
+
24
+ # In-progress requests tracking
25
+ self.pending_requests = {} # hash -> (start_time, event)
26
+ self.request_lock = threading.Lock()
17
27
 
18
28
  # Calculate current space usage
19
29
  self.current_space = sum(f.stat().st_size for f in self.storage_path.glob('*') if f.is_file())
@@ -33,18 +43,196 @@ class Storage:
33
43
  # Store the data
34
44
  file_path.write_bytes(data)
35
45
  self.current_space += data_size
46
+
47
+ # If this was a pending request, mark it as complete
48
+ with self.request_lock:
49
+ if data_hash in self.pending_requests:
50
+ _, event = self.pending_requests[data_hash]
51
+ event.set() # Signal that the data is now available
52
+
36
53
  return True
37
54
 
38
- def get(self, data_hash: bytes) -> Optional[bytes]:
39
- """Retrieve data by its hash. Returns None if not found."""
55
+ def _local_get(self, data_hash: bytes) -> Optional[bytes]:
56
+ """Get data from local storage only, no network requests."""
40
57
  file_path = self.storage_path / data_hash.hex()
41
- if not file_path.exists():
58
+ if file_path.exists():
59
+ return file_path.read_bytes()
60
+ return None
61
+
62
+ def get(self, data_hash: bytes, timeout: Optional[float] = None) -> Optional[bytes]:
63
+ """
64
+ Retrieve data by its hash, with network fallback.
65
+
66
+ This function will first check local storage. If not found and a node is attached,
67
+ it will initiate a network request asynchronously.
68
+
69
+ Args:
70
+ data_hash: The hash of the data to retrieve
71
+ timeout: Timeout in seconds to wait for network request, None for default
72
+
73
+ Returns:
74
+ The data bytes if found, None otherwise
75
+ """
76
+ if timeout is None:
77
+ timeout = self.network_request_timeout
78
+
79
+ # First check local storage
80
+ local_data = self._local_get(data_hash)
81
+ if local_data:
82
+ return local_data
83
+
84
+ # If no node is attached, we can't make network requests
85
+ if self.node is None:
42
86
  return None
43
- return file_path.read_bytes()
87
+
88
+ # Check if there's already a pending request for this hash
89
+ with self.request_lock:
90
+ if data_hash in self.pending_requests:
91
+ start_time, event = self.pending_requests[data_hash]
92
+ # If this request has been going on too long, cancel it and start a new one
93
+ elapsed = time.time() - start_time
94
+ if elapsed > timeout:
95
+ # Cancel the old request
96
+ self.pending_requests.pop(data_hash)
97
+ else:
98
+ # Wait for the existing request to complete
99
+ wait_time = timeout - elapsed
100
+ else:
101
+ # No existing request, create a new one
102
+ event = threading.Event()
103
+ self.pending_requests[data_hash] = (time.time(), event)
104
+ # Start the actual network request in a separate thread
105
+ threading.Thread(
106
+ target=self._request_from_network,
107
+ args=(data_hash,),
108
+ daemon=True
109
+ ).start()
110
+ wait_time = timeout
111
+
112
+ # Wait for the request to complete or timeout
113
+ if event.wait(wait_time):
114
+ # Event was set, data should be available now
115
+ with self.request_lock:
116
+ if data_hash in self.pending_requests:
117
+ self.pending_requests.pop(data_hash)
118
+
119
+ # Check if data is now in local storage
120
+ return self._local_get(data_hash)
121
+ else:
122
+ # Timed out waiting for data
123
+ with self.request_lock:
124
+ if data_hash in self.pending_requests:
125
+ self.pending_requests.pop(data_hash)
126
+ return None
127
+
128
+ def _request_from_network(self, data_hash: bytes):
129
+ """
130
+ Request object from the network.
131
+ This is meant to be run in a separate thread.
132
+
133
+ Args:
134
+ data_hash: The hash of the object to request
135
+ """
136
+ try:
137
+ if hasattr(self.node, 'request_object'):
138
+ # Use the node's request_object method
139
+ self.node.request_object(data_hash)
140
+ # Note: We don't need to return anything or signal completion here
141
+ # The put() method will signal completion when the object is received
142
+ except Exception as e:
143
+ print(f"Error requesting object {data_hash.hex()} from network: {e}")
44
144
 
45
145
  def contains(self, data_hash: bytes) -> bool:
46
146
  """Check if data exists in storage."""
47
147
  return (self.storage_path / data_hash.hex()).exists()
148
+
149
+ def get_recursive(self, root_hash: bytes, max_depth: Optional[int] = None,
150
+ timeout: Optional[float] = None) -> Dict[bytes, bytes]:
151
+ """
152
+ Recursively retrieve all objects starting from a root hash.
153
+
154
+ Objects not found locally will be requested from the network.
155
+ This method will continue processing objects that are available
156
+ while waiting for network responses.
157
+
158
+ Args:
159
+ root_hash: The hash of the root object
160
+ max_depth: Maximum recursion depth, defaults to self.max_object_recursion
161
+ timeout: Time to wait for each object request, None for default
162
+
163
+ Returns:
164
+ Dictionary mapping object hashes to their data
165
+ """
166
+ if max_depth is None:
167
+ max_depth = self.max_object_recursion
168
+
169
+ if timeout is None:
170
+ timeout = self.network_request_timeout
171
+
172
+ # Start with the root object
173
+ objects = {}
174
+ pending_queue = [(root_hash, 0)] # (hash, depth)
175
+ processed = set()
176
+
177
+ # Process objects in the queue
178
+ while pending_queue:
179
+ current_hash, current_depth = pending_queue.pop(0)
180
+
181
+ # Skip if already processed or too deep
182
+ if current_hash in processed or current_depth > max_depth:
183
+ continue
184
+
185
+ processed.add(current_hash)
186
+
187
+ # Try to get the object (which may start a network request)
188
+ obj_data = self.get(current_hash, timeout)
189
+ if obj_data is None:
190
+ # Failed to get this object, but we continue with the rest
191
+ print(f"Warning: Failed to get object {current_hash.hex()}")
192
+ continue
193
+
194
+ # Store the object in our result
195
+ objects[current_hash] = obj_data
196
+
197
+ # Only process non-leaf nodes for recursion
198
+ try:
199
+ # Extract leaf flag and type
200
+ is_leaf = struct.unpack("?", obj_data[0:1])[0]
201
+ if is_leaf:
202
+ # Leaf node, no need to recurse
203
+ continue
204
+
205
+ type_indicator = obj_data[1:2]
206
+ next_depth = current_depth + 1
207
+
208
+ if type_indicator == b'L': # List
209
+ # Non-leaf list has child element hashes
210
+ elements_bytes = obj_data[2:]
211
+ element_hashes = [elements_bytes[i:i+32] for i in range(0, len(elements_bytes), 32)]
212
+
213
+ # Add each element hash to the queue
214
+ for elem_hash in element_hashes:
215
+ pending_queue.append((elem_hash, next_depth))
216
+
217
+ elif type_indicator == b'F': # Function
218
+ # Non-leaf function has body hash
219
+ remaining_bytes = obj_data[2:]
220
+
221
+ # Find the separator between params and body hash
222
+ params_end = remaining_bytes.find(b',', remaining_bytes.rfind(b','))
223
+ if params_end == -1:
224
+ params_end = 0 # No params
225
+
226
+ body_hash = remaining_bytes[params_end+1:]
227
+
228
+ # Add body hash to the queue
229
+ pending_queue.append((body_hash, next_depth))
230
+
231
+ except Exception as e:
232
+ print(f"Error processing object {current_hash.hex()}: {e}")
233
+ continue
234
+
235
+ return objects
48
236
 
49
237
  class Account:
50
238
  def __init__(self, public_key: bytes, balance: int, counter: int):
@@ -11,20 +11,43 @@ from .envelope import Envelope
11
11
  from .bucket import KBucket
12
12
  from .peer import Peer, PeerManager
13
13
  from .route import RouteTable
14
+ import json
15
+ from cryptography.hazmat.primitives.asymmetric import ed25519
14
16
 
15
17
  class Relay:
16
18
  def __init__(self, config: dict):
19
+ """Initialize relay with configuration."""
20
+ self.config = config
17
21
  self.use_ipv6 = config.get('use_ipv6', False)
18
22
  incoming_port = config.get('incoming_port', 7373)
19
23
  self.max_message_size = config.get('max_message_size', 65536) # Max UDP datagram size
20
24
  self.num_workers = config.get('num_workers', 4)
21
25
 
26
+ # Generate Ed25519 keypair for this node
27
+ if 'private_key' in config:
28
+ # Load existing private key if provided
29
+ try:
30
+ private_key_bytes = bytes.fromhex(config['private_key'])
31
+ self.private_key = ed25519.Ed25519PrivateKey.from_private_bytes(private_key_bytes)
32
+ except Exception as e:
33
+ print(f"Error loading private key: {e}, generating new one")
34
+ self.private_key = ed25519.Ed25519PrivateKey.generate()
35
+ else:
36
+ # Generate new keypair
37
+ self.private_key = ed25519.Ed25519PrivateKey.generate()
38
+
39
+ # Use public key as node ID
40
+ self.public_key = self.private_key.public_key()
41
+ self.node_id = self.public_key.public_bytes_raw()
42
+
43
+ # Save private key bytes for config persistence
44
+ self.private_key_bytes = self.private_key.private_bytes_raw()
45
+
22
46
  # Routes that this node participates in (0 = peer route, 1 = validation route)
23
47
  self.routes: List[int] = []
24
48
 
25
- # Initialize routes from config if provided
26
- if config.get('peer_route', False):
27
- self.routes.append(0) # Peer route
49
+ # Peer route is always enabled
50
+ self.routes.append(0) # Peer route
28
51
 
29
52
  if config.get('validation_route', False):
30
53
  self.routes.append(1) # Validation route
@@ -54,11 +77,18 @@ class Relay:
54
77
  self.outgoing_queue = Queue()
55
78
 
56
79
  # Message handling
57
- self.message_handlers: Dict[Topic, Callable] = {}
80
+ self.message_handlers: Dict[Topic, Callable] = {
81
+ Topic.PEER_ROUTE: None, # set by Node later
82
+ Topic.OBJECT_REQUEST: None, # set by Node later
83
+ Topic.OBJECT_RESPONSE: None, # set by Node later
84
+ }
58
85
 
59
86
  # Route buckets (peers for each route)
60
87
  self.peer_route_bucket = KBucket(k=20) # Bucket for peer route
61
88
  self.validation_route_bucket = KBucket(k=20) # Bucket for validation route
89
+
90
+ # Initialize route table with our node ID
91
+ self.route_table = RouteTable(self)
62
92
 
63
93
  # Start worker threads
64
94
  self._start_workers()
@@ -197,6 +227,12 @@ class Relay:
197
227
  elif envelope.message.topic in (Topic.LATEST_BLOCK_REQUEST, Topic.GET_BLOCKS):
198
228
  # Allow all nodes to request blocks for syncing
199
229
  self.message_handlers[envelope.message.topic](envelope.message.body, addr, envelope)
230
+ elif envelope.message.topic == Topic.OBJECT_REQUEST:
231
+ # Handle object request
232
+ self.message_handlers[envelope.message.topic](envelope.message.body, addr, envelope)
233
+ elif envelope.message.topic == Topic.OBJECT_RESPONSE:
234
+ # Handle object response
235
+ self.message_handlers[envelope.message.topic](envelope.message.body, addr, envelope)
200
236
  else:
201
237
  # For other message types, always process
202
238
  self.message_handlers[envelope.message.topic](envelope.message.body, addr, envelope)
@@ -240,9 +276,69 @@ class Relay:
240
276
  encoded_data = envelope.to_bytes()
241
277
  self.send(encoded_data, addr)
242
278
 
279
+ def send_message_to_addr(self, addr: tuple, topic: Topic, body: bytes):
280
+ """
281
+ Send a message to a specific address.
282
+
283
+ Args:
284
+ addr: Tuple of (ip, port) to send to
285
+ topic: Message topic
286
+ body: Message body
287
+ """
288
+ try:
289
+ # Create an envelope with our node id and the message
290
+ message = Message(self.node_id, topic, body)
291
+ envelope = Envelope(message)
292
+
293
+ # Serialize and send
294
+ self.outgoing_socket.sendto(envelope.to_bytes(), addr)
295
+ except Exception as e:
296
+ print(f"Error sending message to {addr}: {e}")
297
+
298
+ def send_message_to_peer(self, peer: Peer, topic: Topic, body):
299
+ """
300
+ Send a message to a specific peer.
301
+
302
+ Args:
303
+ peer: Peer to send to
304
+ topic: Message topic
305
+ body: Message body (bytes or JSON serializable)
306
+ """
307
+ # Convert body to bytes if it's not already
308
+ if not isinstance(body, bytes):
309
+ if isinstance(body, dict) or isinstance(body, list):
310
+ body = json.dumps(body).encode('utf-8')
311
+ else:
312
+ body = str(body).encode('utf-8')
313
+
314
+ # Send to the peer's address
315
+ self.send_message_to_addr(peer.address, topic, body)
316
+
243
317
  def stop(self):
244
318
  """Stop all worker threads."""
245
319
  self.running = False
246
320
  # Wait for queues to be processed
247
321
  self.incoming_queue.join()
248
322
  self.outgoing_queue.join()
323
+
324
+ # RouteTable wrapper methods
325
+ def add_peer(self, addr, public_key, difficulty):
326
+ """Add a peer to the routing table."""
327
+ return self.route_table.update_peer(addr, public_key, difficulty)
328
+
329
+ def get_closest_peers(self, target_id, count=3):
330
+ """Get the closest peers to the target ID."""
331
+ return self.route_table.get_closest_peers(target_id, count=count)
332
+
333
+ @property
334
+ def num_buckets(self):
335
+ """Get the number of buckets in the routing table."""
336
+ return self.route_table.num_buckets
337
+
338
+ def get_bucket_peers(self, bucket_index):
339
+ """Get peers from a specific bucket."""
340
+ return self.route_table.get_bucket_peers(bucket_index)
341
+
342
+ def has_peer(self, addr):
343
+ """Check if a peer with the given address exists in the routing table."""
344
+ return self.route_table.has_peer(addr)