hive-nectar 0.2.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.
- hive_nectar-0.2.9.dist-info/METADATA +194 -0
- hive_nectar-0.2.9.dist-info/RECORD +87 -0
- hive_nectar-0.2.9.dist-info/WHEEL +4 -0
- hive_nectar-0.2.9.dist-info/entry_points.txt +2 -0
- hive_nectar-0.2.9.dist-info/licenses/LICENSE.txt +23 -0
- nectar/__init__.py +37 -0
- nectar/account.py +5076 -0
- nectar/amount.py +553 -0
- nectar/asciichart.py +303 -0
- nectar/asset.py +122 -0
- nectar/block.py +574 -0
- nectar/blockchain.py +1242 -0
- nectar/blockchaininstance.py +2590 -0
- nectar/blockchainobject.py +263 -0
- nectar/cli.py +5937 -0
- nectar/comment.py +1552 -0
- nectar/community.py +854 -0
- nectar/constants.py +95 -0
- nectar/discussions.py +1437 -0
- nectar/exceptions.py +152 -0
- nectar/haf.py +381 -0
- nectar/hive.py +630 -0
- nectar/imageuploader.py +114 -0
- nectar/instance.py +113 -0
- nectar/market.py +876 -0
- nectar/memo.py +542 -0
- nectar/message.py +379 -0
- nectar/nodelist.py +309 -0
- nectar/price.py +603 -0
- nectar/profile.py +74 -0
- nectar/py.typed +0 -0
- nectar/rc.py +333 -0
- nectar/snapshot.py +1024 -0
- nectar/storage.py +62 -0
- nectar/transactionbuilder.py +659 -0
- nectar/utils.py +630 -0
- nectar/version.py +3 -0
- nectar/vote.py +722 -0
- nectar/wallet.py +472 -0
- nectar/witness.py +728 -0
- nectarapi/__init__.py +12 -0
- nectarapi/exceptions.py +126 -0
- nectarapi/graphenerpc.py +596 -0
- nectarapi/node.py +194 -0
- nectarapi/noderpc.py +79 -0
- nectarapi/openapi.py +107 -0
- nectarapi/py.typed +0 -0
- nectarapi/rpcutils.py +98 -0
- nectarapi/version.py +3 -0
- nectarbase/__init__.py +15 -0
- nectarbase/ledgertransactions.py +106 -0
- nectarbase/memo.py +242 -0
- nectarbase/objects.py +521 -0
- nectarbase/objecttypes.py +21 -0
- nectarbase/operationids.py +102 -0
- nectarbase/operations.py +1357 -0
- nectarbase/py.typed +0 -0
- nectarbase/signedtransactions.py +89 -0
- nectarbase/transactions.py +11 -0
- nectarbase/version.py +3 -0
- nectargraphenebase/__init__.py +27 -0
- nectargraphenebase/account.py +1121 -0
- nectargraphenebase/aes.py +49 -0
- nectargraphenebase/base58.py +197 -0
- nectargraphenebase/bip32.py +575 -0
- nectargraphenebase/bip38.py +110 -0
- nectargraphenebase/chains.py +15 -0
- nectargraphenebase/dictionary.py +2 -0
- nectargraphenebase/ecdsasig.py +309 -0
- nectargraphenebase/objects.py +130 -0
- nectargraphenebase/objecttypes.py +8 -0
- nectargraphenebase/operationids.py +5 -0
- nectargraphenebase/operations.py +25 -0
- nectargraphenebase/prefix.py +13 -0
- nectargraphenebase/py.typed +0 -0
- nectargraphenebase/signedtransactions.py +221 -0
- nectargraphenebase/types.py +557 -0
- nectargraphenebase/unsignedtransactions.py +288 -0
- nectargraphenebase/version.py +3 -0
- nectarstorage/__init__.py +57 -0
- nectarstorage/base.py +317 -0
- nectarstorage/exceptions.py +15 -0
- nectarstorage/interfaces.py +244 -0
- nectarstorage/masterpassword.py +237 -0
- nectarstorage/py.typed +0 -0
- nectarstorage/ram.py +27 -0
- nectarstorage/sqlite.py +343 -0
nectar/message.py
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import re
|
|
4
|
+
from binascii import hexlify, unhexlify
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any, Optional, Union
|
|
7
|
+
|
|
8
|
+
from nectar.account import Account
|
|
9
|
+
from nectar.instance import shared_blockchain_instance
|
|
10
|
+
from nectargraphenebase.account import PublicKey
|
|
11
|
+
from nectargraphenebase.ecdsasig import sign_message, verify_message
|
|
12
|
+
|
|
13
|
+
from .exceptions import (
|
|
14
|
+
AccountDoesNotExistsException,
|
|
15
|
+
InvalidMemoKeyException,
|
|
16
|
+
InvalidMessageSignature,
|
|
17
|
+
WrongMemoKey,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MessageV1:
|
|
24
|
+
"""Allow to sign and verify Messages that are sigend with a private key"""
|
|
25
|
+
|
|
26
|
+
MESSAGE_SPLIT = (
|
|
27
|
+
"-----BEGIN HIVE SIGNED MESSAGE-----",
|
|
28
|
+
"-----BEGIN META-----",
|
|
29
|
+
"-----BEGIN SIGNATURE-----",
|
|
30
|
+
"-----END HIVE SIGNED MESSAGE-----",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# This is the message that is actually signed
|
|
34
|
+
SIGNED_MESSAGE_META = """{message}
|
|
35
|
+
account={meta[account]}
|
|
36
|
+
memokey={meta[memokey]}
|
|
37
|
+
block={meta[block]}
|
|
38
|
+
timestamp={meta[timestamp]}"""
|
|
39
|
+
|
|
40
|
+
SIGNED_MESSAGE_ENCAPSULATED = """
|
|
41
|
+
{MESSAGE_SPLIT[0]}
|
|
42
|
+
{message}
|
|
43
|
+
{MESSAGE_SPLIT[1]}
|
|
44
|
+
account={meta[account]}
|
|
45
|
+
memokey={meta[memokey]}
|
|
46
|
+
block={meta[block]}
|
|
47
|
+
timestamp={meta[timestamp]}
|
|
48
|
+
{MESSAGE_SPLIT[2]}
|
|
49
|
+
{signature}
|
|
50
|
+
{MESSAGE_SPLIT[3]}"""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self, message: str, blockchain_instance: Optional[Any] = None, *args: Any, **kwargs: Any
|
|
54
|
+
) -> None:
|
|
55
|
+
"""
|
|
56
|
+
Initialize the message handler, normalize line endings, and set up signing context.
|
|
57
|
+
|
|
58
|
+
Parameters:
|
|
59
|
+
message (str): The raw message text to be signed or verified. Line endings are normalized to LF.
|
|
60
|
+
|
|
61
|
+
Description:
|
|
62
|
+
- Assigns self.blockchain to the provided blockchain_instance or to shared_blockchain_instance() when none is given.
|
|
63
|
+
- Normalizes CRLF ("\r\n") to LF ("\n") and stores the result in self.message.
|
|
64
|
+
- Initializes signing/verification context attributes: signed_by_account, signed_by_name, meta, and plain_message to None.
|
|
65
|
+
"""
|
|
66
|
+
self.blockchain = blockchain_instance or shared_blockchain_instance()
|
|
67
|
+
self.message = message.replace("\r\n", "\n")
|
|
68
|
+
self.signed_by_account = None
|
|
69
|
+
self.signed_by_name = None
|
|
70
|
+
self.meta = None
|
|
71
|
+
self.plain_message = None
|
|
72
|
+
|
|
73
|
+
def sign(self, account: Optional[Union[str, Account]] = None, **kwargs: Any) -> Any:
|
|
74
|
+
"""Sign a message with an account's memo key
|
|
75
|
+
:param str account: (optional) the account that owns the bet
|
|
76
|
+
(defaults to ``default_account``)
|
|
77
|
+
:raises ValueError: If not account for signing is provided
|
|
78
|
+
:returns: the signed message encapsulated in a known format
|
|
79
|
+
"""
|
|
80
|
+
if not account:
|
|
81
|
+
if "default_account" in self.blockchain.config:
|
|
82
|
+
account = self.blockchain.config["default_account"]
|
|
83
|
+
if not account:
|
|
84
|
+
raise ValueError("You need to provide an account")
|
|
85
|
+
|
|
86
|
+
# Data for message
|
|
87
|
+
account = Account(account, blockchain_instance=self.blockchain)
|
|
88
|
+
info = self.blockchain.info()
|
|
89
|
+
meta = dict(
|
|
90
|
+
timestamp=info["time"],
|
|
91
|
+
block=info["head_block_number"],
|
|
92
|
+
memokey=account["memo_key"],
|
|
93
|
+
account=account["name"],
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# wif key
|
|
97
|
+
wif = self.blockchain.wallet.getPrivateKeyForPublicKey(account["memo_key"])
|
|
98
|
+
|
|
99
|
+
# We strip the message here so we know for sure there are no trailing
|
|
100
|
+
# whitespaces or returns
|
|
101
|
+
message = self.message.strip()
|
|
102
|
+
|
|
103
|
+
enc_message = self.SIGNED_MESSAGE_META.format(**locals())
|
|
104
|
+
|
|
105
|
+
# signature
|
|
106
|
+
signature = hexlify(sign_message(enc_message, wif)).decode("ascii")
|
|
107
|
+
|
|
108
|
+
self.signed_by_account = account
|
|
109
|
+
self.signed_by_name = account["name"]
|
|
110
|
+
self.meta = meta
|
|
111
|
+
self.plain_message = message
|
|
112
|
+
|
|
113
|
+
return self.SIGNED_MESSAGE_ENCAPSULATED.format(MESSAGE_SPLIT=self.MESSAGE_SPLIT, **locals())
|
|
114
|
+
|
|
115
|
+
def verify(self, **kwargs: Any) -> bool:
|
|
116
|
+
"""Verify a message with an account's memo key
|
|
117
|
+
:param str account: (optional) the account that owns the bet
|
|
118
|
+
(defaults to ``default_account``)
|
|
119
|
+
:returns: True if the message is verified successfully
|
|
120
|
+
:raises InvalidMessageSignature if the signature is not ok
|
|
121
|
+
"""
|
|
122
|
+
# Split message into its parts
|
|
123
|
+
parts = re.split("|".join(self.MESSAGE_SPLIT), self.message)
|
|
124
|
+
parts = [x for x in parts if x.strip()]
|
|
125
|
+
|
|
126
|
+
assert len(parts) > 2, "Incorrect number of message parts"
|
|
127
|
+
|
|
128
|
+
# Strip away all whitespaces before and after the message
|
|
129
|
+
message = parts[0].strip()
|
|
130
|
+
signature = parts[2].strip()
|
|
131
|
+
# Parse the meta data
|
|
132
|
+
meta = dict(re.findall(r"(\S+)=(.*)", parts[1]))
|
|
133
|
+
|
|
134
|
+
log.info(f"Message is: {message}")
|
|
135
|
+
log.info(f"Meta is: {json.dumps(meta)}")
|
|
136
|
+
log.info(f"Signature is: {signature}")
|
|
137
|
+
|
|
138
|
+
# Ensure we have all the data in meta
|
|
139
|
+
assert "account" in meta, "No 'account' could be found in meta data"
|
|
140
|
+
assert "memokey" in meta, "No 'memokey' could be found in meta data"
|
|
141
|
+
assert "block" in meta, "No 'block' could be found in meta data"
|
|
142
|
+
assert "timestamp" in meta, "No 'timestamp' could be found in meta data"
|
|
143
|
+
|
|
144
|
+
account_name = meta.get("account", "").strip()
|
|
145
|
+
memo_key = meta.get("memokey", "").strip()
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
PublicKey(memo_key, prefix=self.blockchain.prefix)
|
|
149
|
+
except Exception:
|
|
150
|
+
raise InvalidMemoKeyException("The memo key in the message is invalid")
|
|
151
|
+
|
|
152
|
+
# Load account from blockchain
|
|
153
|
+
try:
|
|
154
|
+
account = Account(account_name, blockchain_instance=self.blockchain)
|
|
155
|
+
except AccountDoesNotExistsException:
|
|
156
|
+
raise AccountDoesNotExistsException(
|
|
157
|
+
"Could not find account {}. Are you connected to the right chain?".format(
|
|
158
|
+
account_name
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# Test if memo key is the same as on the blockchain
|
|
163
|
+
if not account["memo_key"] == memo_key:
|
|
164
|
+
raise WrongMemoKey(
|
|
165
|
+
"Memo Key of account {} on the Blockchain ".format(account["name"])
|
|
166
|
+
+ "differs from memo key in the message: {} != {}".format(
|
|
167
|
+
account["memo_key"], memo_key
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# Reformat message
|
|
172
|
+
enc_message = self.SIGNED_MESSAGE_META.format(**locals())
|
|
173
|
+
|
|
174
|
+
# Verify Signature
|
|
175
|
+
pubkey = verify_message(enc_message, unhexlify(signature))
|
|
176
|
+
if pubkey is None:
|
|
177
|
+
raise InvalidMessageSignature("No public key recovered from signature")
|
|
178
|
+
|
|
179
|
+
# Verify pubky
|
|
180
|
+
pk = PublicKey(hexlify(pubkey).decode("ascii"), prefix=self.blockchain.prefix)
|
|
181
|
+
if format(pk, self.blockchain.prefix) != memo_key:
|
|
182
|
+
raise InvalidMessageSignature("The signature doesn't match the memo key")
|
|
183
|
+
|
|
184
|
+
self.signed_by_account = account
|
|
185
|
+
self.signed_by_name = account["name"]
|
|
186
|
+
self.meta = meta
|
|
187
|
+
self.plain_message = message
|
|
188
|
+
|
|
189
|
+
return True
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class MessageV2:
|
|
193
|
+
"""Allow to sign and verify Messages that are sigend with a private key"""
|
|
194
|
+
|
|
195
|
+
def __init__(
|
|
196
|
+
self, message: str, blockchain_instance: Optional[Any] = None, *args: Any, **kwargs: Any
|
|
197
|
+
) -> None:
|
|
198
|
+
"""
|
|
199
|
+
Initialize the message handler and set up default signing context.
|
|
200
|
+
|
|
201
|
+
Parameters:
|
|
202
|
+
message (str): The raw message text to be signed or verified.
|
|
203
|
+
|
|
204
|
+
Description:
|
|
205
|
+
Stores the provided message and sets up the signing context attributes
|
|
206
|
+
(signed_by_account, signed_by_name, meta, plain_message) to None.
|
|
207
|
+
If no blockchain instance is supplied, assigns a shared blockchain instance
|
|
208
|
+
via shared_blockchain_instance().
|
|
209
|
+
"""
|
|
210
|
+
self.blockchain = blockchain_instance or shared_blockchain_instance()
|
|
211
|
+
|
|
212
|
+
self.message = message
|
|
213
|
+
self.signed_by_account = None
|
|
214
|
+
self.signed_by_name = None
|
|
215
|
+
self.meta = None
|
|
216
|
+
self.plain_message = None
|
|
217
|
+
|
|
218
|
+
def sign(self, account: Optional[Union[str, Account]] = None, **kwargs: Any) -> Any:
|
|
219
|
+
"""Sign a message with an account's memo key
|
|
220
|
+
:param str account: (optional) the account that owns the bet
|
|
221
|
+
(defaults to ``default_account``)
|
|
222
|
+
:raises ValueError: If not account for signing is provided
|
|
223
|
+
:returns: the signed message encapsulated in a known format
|
|
224
|
+
"""
|
|
225
|
+
if not account:
|
|
226
|
+
if "default_account" in self.blockchain.config:
|
|
227
|
+
account = self.blockchain.config["default_account"]
|
|
228
|
+
if not account:
|
|
229
|
+
raise ValueError("You need to provide an account")
|
|
230
|
+
|
|
231
|
+
# Data for message
|
|
232
|
+
account = Account(account, blockchain_instance=self.blockchain)
|
|
233
|
+
|
|
234
|
+
# wif key
|
|
235
|
+
wif = self.blockchain.wallet.getPrivateKeyForPublicKey(account["memo_key"])
|
|
236
|
+
|
|
237
|
+
payload = [
|
|
238
|
+
"from",
|
|
239
|
+
account["name"],
|
|
240
|
+
"key",
|
|
241
|
+
account["memo_key"],
|
|
242
|
+
"time",
|
|
243
|
+
str(datetime.now(timezone.utc)),
|
|
244
|
+
"text",
|
|
245
|
+
self.message,
|
|
246
|
+
]
|
|
247
|
+
enc_message = json.dumps(payload, separators=(",", ":"))
|
|
248
|
+
|
|
249
|
+
# signature
|
|
250
|
+
signature = hexlify(sign_message(enc_message, wif)).decode("ascii")
|
|
251
|
+
|
|
252
|
+
return dict(signed=enc_message, payload=payload, signature=signature)
|
|
253
|
+
|
|
254
|
+
def verify(self, **kwargs: Any) -> bool:
|
|
255
|
+
"""Verify a message with an account's memo key
|
|
256
|
+
:param str account: (optional) the account that owns the bet
|
|
257
|
+
(defaults to ``default_account``)
|
|
258
|
+
:returns: True if the message is verified successfully
|
|
259
|
+
:raises InvalidMessageSignature if the signature is not ok
|
|
260
|
+
"""
|
|
261
|
+
if not isinstance(self.message, dict):
|
|
262
|
+
try:
|
|
263
|
+
self.message = json.loads(self.message)
|
|
264
|
+
except Exception:
|
|
265
|
+
raise ValueError("Message must be valid JSON")
|
|
266
|
+
|
|
267
|
+
payload = self.message.get("payload")
|
|
268
|
+
assert payload, "Missing payload"
|
|
269
|
+
payload_dict = {k[0]: k[1] for k in zip(payload[::2], payload[1::2])}
|
|
270
|
+
signature = self.message.get("signature")
|
|
271
|
+
|
|
272
|
+
account_name = payload_dict.get("from", "").strip()
|
|
273
|
+
memo_key = payload_dict.get("key", "").strip()
|
|
274
|
+
|
|
275
|
+
assert account_name, "Missing account name 'from'"
|
|
276
|
+
assert memo_key, "missing 'key'"
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
# Validate that the memo key is a syntactically valid public key
|
|
280
|
+
PublicKey(memo_key, prefix=self.blockchain.prefix)
|
|
281
|
+
except Exception:
|
|
282
|
+
raise InvalidMemoKeyException("The memo key in the message is invalid")
|
|
283
|
+
|
|
284
|
+
# Load account from blockchain
|
|
285
|
+
try:
|
|
286
|
+
account = Account(account_name, blockchain_instance=self.blockchain)
|
|
287
|
+
except AccountDoesNotExistsException:
|
|
288
|
+
raise AccountDoesNotExistsException(
|
|
289
|
+
"Could not find account {}. Are you connected to the right chain?".format(
|
|
290
|
+
account_name
|
|
291
|
+
)
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
# Test if memo key is the same as on the blockchain
|
|
295
|
+
if not account["memo_key"] == memo_key:
|
|
296
|
+
raise WrongMemoKey(
|
|
297
|
+
"Memo Key of account {} on the Blockchain ".format(account["name"])
|
|
298
|
+
+ "differs from memo key in the message: {} != {}".format(
|
|
299
|
+
account["memo_key"], memo_key
|
|
300
|
+
)
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
# Ensure payload and signed match
|
|
304
|
+
signed_target = json.dumps(self.message.get("payload"), separators=(",", ":"))
|
|
305
|
+
signed_actual = self.message.get("signed")
|
|
306
|
+
assert signed_target == signed_actual, (
|
|
307
|
+
"payload doesn't match signed message: \n{}\n{}".format(signed_target, signed_actual)
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
# Reformat message
|
|
311
|
+
enc_message = self.message.get("signed")
|
|
312
|
+
|
|
313
|
+
# Verify Signature
|
|
314
|
+
pubkey = verify_message(enc_message, unhexlify(signature))
|
|
315
|
+
|
|
316
|
+
# Verify pubky
|
|
317
|
+
if pubkey is None:
|
|
318
|
+
raise InvalidMessageSignature("No public key recovered from signature")
|
|
319
|
+
pk = PublicKey(hexlify(pubkey).decode("ascii"), prefix=self.blockchain.prefix)
|
|
320
|
+
if format(pk, self.blockchain.prefix) != memo_key:
|
|
321
|
+
raise InvalidMessageSignature("The signature doesn't match the memo key")
|
|
322
|
+
|
|
323
|
+
self.signed_by_account = account
|
|
324
|
+
self.signed_by_name = account["name"]
|
|
325
|
+
self.plain_message = payload_dict.get("text")
|
|
326
|
+
|
|
327
|
+
return True
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
class Message(MessageV1, MessageV2):
|
|
331
|
+
supported_formats = (MessageV1, MessageV2)
|
|
332
|
+
valid_exceptions = (
|
|
333
|
+
AccountDoesNotExistsException,
|
|
334
|
+
InvalidMessageSignature,
|
|
335
|
+
WrongMemoKey,
|
|
336
|
+
InvalidMemoKeyException,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
340
|
+
for _format in self.supported_formats:
|
|
341
|
+
try:
|
|
342
|
+
_format.__init__(self, *args, **kwargs)
|
|
343
|
+
return
|
|
344
|
+
except self.valid_exceptions as e:
|
|
345
|
+
raise e
|
|
346
|
+
except Exception as e:
|
|
347
|
+
log.warning(
|
|
348
|
+
"{}: Couldn't init: {}: {}".format(
|
|
349
|
+
_format.__name__, e.__class__.__name__, str(e)
|
|
350
|
+
)
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
def verify(self, **kwargs: Any) -> bool:
|
|
354
|
+
for _format in self.supported_formats:
|
|
355
|
+
try:
|
|
356
|
+
return _format.verify(self, **kwargs)
|
|
357
|
+
except self.valid_exceptions as e:
|
|
358
|
+
raise e
|
|
359
|
+
except Exception as e:
|
|
360
|
+
log.warning(
|
|
361
|
+
"{}: Couldn't verify: {}: {}".format(
|
|
362
|
+
_format.__name__, e.__class__.__name__, str(e)
|
|
363
|
+
)
|
|
364
|
+
)
|
|
365
|
+
raise ValueError("No Decoder accepted the message")
|
|
366
|
+
|
|
367
|
+
def sign(self, account: Optional[Union[str, Account]] = None, **kwargs: Any) -> Any:
|
|
368
|
+
for _format in self.supported_formats:
|
|
369
|
+
try:
|
|
370
|
+
return _format.sign(self, account=account, **kwargs)
|
|
371
|
+
except self.valid_exceptions as e:
|
|
372
|
+
raise e
|
|
373
|
+
except Exception as e:
|
|
374
|
+
log.warning(
|
|
375
|
+
"{}: Couldn't sign: {}: {}".format(
|
|
376
|
+
_format.__name__, e.__class__.__name__, str(e)
|
|
377
|
+
)
|
|
378
|
+
)
|
|
379
|
+
raise ValueError("No Decoder accepted the message")
|
nectar/nodelist.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
log = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
# Static fallback nodes in case beacon is unavailable
|
|
12
|
+
STATIC_NODES = [
|
|
13
|
+
"https://api.hive.blog",
|
|
14
|
+
"https://api.openhive.network",
|
|
15
|
+
"https://api.syncad.com",
|
|
16
|
+
"https://api.deathwing.me",
|
|
17
|
+
"https://api.c0ff33a.uk",
|
|
18
|
+
"https://hive-api.3speak.tv",
|
|
19
|
+
"https://hiveapi.actifit.io",
|
|
20
|
+
"https://rpc.mahdiyari.info",
|
|
21
|
+
"https://techcoderx.com",
|
|
22
|
+
"https://anyx.io",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
BEACON_URL = "https://beacon.peakd.com/api/nodes"
|
|
26
|
+
REQUEST_TIMEOUT = 10 # seconds
|
|
27
|
+
CACHE_DURATION = 300 # 5 minutes cache
|
|
28
|
+
|
|
29
|
+
# Global cache for node data
|
|
30
|
+
_cached_nodes: Optional[List[Dict[str, Any]]] = None
|
|
31
|
+
_cache_timestamp: float = 0
|
|
32
|
+
_cache_lock = threading.Lock()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def fetch_beacon_nodes() -> Optional[List[Dict[str, Any]]]:
|
|
36
|
+
"""Fetch node list from PeakD beacon API with caching.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
List of node dictionaries from beacon API, or None if fetch fails
|
|
40
|
+
"""
|
|
41
|
+
global _cached_nodes, _cache_timestamp
|
|
42
|
+
|
|
43
|
+
current_time = time.time()
|
|
44
|
+
|
|
45
|
+
# Return cached data if still valid
|
|
46
|
+
with _cache_lock:
|
|
47
|
+
if _cached_nodes is not None and current_time - _cache_timestamp < CACHE_DURATION:
|
|
48
|
+
log.debug("Using cached beacon nodes")
|
|
49
|
+
return _cached_nodes
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
log.debug("Fetching fresh nodes from beacon API")
|
|
53
|
+
with httpx.Client(timeout=REQUEST_TIMEOUT) as client:
|
|
54
|
+
response = client.get(BEACON_URL, headers={"Accept": "*/*"})
|
|
55
|
+
response.raise_for_status()
|
|
56
|
+
nodes = response.json()
|
|
57
|
+
|
|
58
|
+
# Cache the successful result
|
|
59
|
+
with _cache_lock:
|
|
60
|
+
_cached_nodes = nodes
|
|
61
|
+
_cache_timestamp = current_time
|
|
62
|
+
return nodes
|
|
63
|
+
except (httpx.RequestError, httpx.HTTPStatusError, json.JSONDecodeError, Exception) as e:
|
|
64
|
+
log.warning(f"Failed to fetch nodes from beacon API: {e}")
|
|
65
|
+
# Return cached data even if expired, as fallback
|
|
66
|
+
with _cache_lock:
|
|
67
|
+
if _cached_nodes is not None:
|
|
68
|
+
log.info("Using expired cached nodes as fallback")
|
|
69
|
+
return _cached_nodes
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def clear_beacon_cache() -> None:
|
|
74
|
+
"""Clear the cached beacon node data.
|
|
75
|
+
|
|
76
|
+
This forces the next NodeList() instantiation or update_nodes() call
|
|
77
|
+
to fetch fresh data from the beacon API.
|
|
78
|
+
"""
|
|
79
|
+
global _cached_nodes, _cache_timestamp
|
|
80
|
+
with _cache_lock:
|
|
81
|
+
_cached_nodes = None
|
|
82
|
+
_cache_timestamp = 0
|
|
83
|
+
log.debug("Beacon node cache cleared")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class NodeList(list):
|
|
87
|
+
"""Simplified Hive node list using PeakD beacon API.
|
|
88
|
+
|
|
89
|
+
Fetches real-time node information from PeakD beacon API with static fallback.
|
|
90
|
+
|
|
91
|
+
.. code-block:: python
|
|
92
|
+
|
|
93
|
+
from nectar.nodelist import NodeList
|
|
94
|
+
n = NodeList()
|
|
95
|
+
nodes_urls = n.get_nodes()
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(self):
|
|
99
|
+
"""Initialize NodeList with nodes from beacon API or static fallback."""
|
|
100
|
+
super().__init__()
|
|
101
|
+
self._refresh_nodes()
|
|
102
|
+
|
|
103
|
+
def _refresh_nodes(self) -> None:
|
|
104
|
+
"""Refresh node list from beacon API or use static fallback."""
|
|
105
|
+
beacon_nodes = fetch_beacon_nodes()
|
|
106
|
+
|
|
107
|
+
if beacon_nodes:
|
|
108
|
+
# Convert beacon format to our internal format
|
|
109
|
+
nodes = []
|
|
110
|
+
for node in beacon_nodes:
|
|
111
|
+
# Only include nodes with decent performance (score > 0)
|
|
112
|
+
if node.get("score", 0) > 0:
|
|
113
|
+
nodes.append(
|
|
114
|
+
{
|
|
115
|
+
"url": node["endpoint"],
|
|
116
|
+
"version": node.get("version", "unknown"),
|
|
117
|
+
"type": "appbase", # All beacon nodes are appbase
|
|
118
|
+
"owner": node.get("name", "unknown"),
|
|
119
|
+
"hive": True,
|
|
120
|
+
"score": node.get("score", 0),
|
|
121
|
+
}
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# Sort by score (highest first)
|
|
125
|
+
nodes.sort(key=lambda x: x["score"], reverse=True)
|
|
126
|
+
super().__init__(nodes)
|
|
127
|
+
log.info(f"Loaded {len(nodes)} nodes from PeakD beacon API")
|
|
128
|
+
else:
|
|
129
|
+
# Use static fallback
|
|
130
|
+
nodes = [
|
|
131
|
+
{
|
|
132
|
+
"url": url,
|
|
133
|
+
"version": "unknown",
|
|
134
|
+
"type": "appbase",
|
|
135
|
+
"owner": "static",
|
|
136
|
+
"hive": True,
|
|
137
|
+
"score": 50,
|
|
138
|
+
}
|
|
139
|
+
for url in STATIC_NODES
|
|
140
|
+
]
|
|
141
|
+
super().__init__(nodes)
|
|
142
|
+
log.warning(f"Using static fallback nodes ({len(nodes)} nodes)")
|
|
143
|
+
|
|
144
|
+
def get_nodes(
|
|
145
|
+
self,
|
|
146
|
+
hive: bool = True,
|
|
147
|
+
dev: bool = False,
|
|
148
|
+
testnet: bool = False,
|
|
149
|
+
testnetdev: bool = False,
|
|
150
|
+
wss: bool = True,
|
|
151
|
+
https: bool = True,
|
|
152
|
+
not_working: bool = False,
|
|
153
|
+
normal: bool = False,
|
|
154
|
+
appbase: bool = True,
|
|
155
|
+
) -> List[str]:
|
|
156
|
+
"""Return a list of node URLs filtered and sorted by score.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
hive: Filter for Hive nodes only (default: True)
|
|
160
|
+
dev: Include dev nodes (not applicable with beacon)
|
|
161
|
+
testnet: Include testnet nodes (not applicable with beacon)
|
|
162
|
+
testnetdev: Include testnet dev nodes (not applicable with beacon)
|
|
163
|
+
wss: Include WebSocket nodes (default: True)
|
|
164
|
+
https: Include HTTPS nodes (default: True)
|
|
165
|
+
not_working: Include nodes with negative scores (default: False)
|
|
166
|
+
normal: Include normal nodes (deprecated, default: False)
|
|
167
|
+
appbase: Include appbase nodes (default: True)
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
List of node URLs sorted by score (highest first)
|
|
171
|
+
"""
|
|
172
|
+
filtered_nodes = []
|
|
173
|
+
|
|
174
|
+
# Determine allowed types based on flags (OR logic)
|
|
175
|
+
allowed_types = set()
|
|
176
|
+
if appbase:
|
|
177
|
+
allowed_types.add("appbase")
|
|
178
|
+
if normal:
|
|
179
|
+
allowed_types.add("normal")
|
|
180
|
+
if dev:
|
|
181
|
+
allowed_types.add("appbase-dev")
|
|
182
|
+
if testnet:
|
|
183
|
+
allowed_types.add("testnet")
|
|
184
|
+
if testnetdev:
|
|
185
|
+
allowed_types.add("testnet-dev")
|
|
186
|
+
|
|
187
|
+
for node in self:
|
|
188
|
+
# Filter by score
|
|
189
|
+
if node["score"] < 0 and not not_working:
|
|
190
|
+
continue
|
|
191
|
+
|
|
192
|
+
# Filter by Hive
|
|
193
|
+
if hive and not node["hive"]:
|
|
194
|
+
continue
|
|
195
|
+
|
|
196
|
+
# Filter by protocol
|
|
197
|
+
if not https and node["url"].startswith("https"):
|
|
198
|
+
continue
|
|
199
|
+
if not wss and node["url"].startswith("wss"):
|
|
200
|
+
continue
|
|
201
|
+
|
|
202
|
+
# Filter by type (OR logic)
|
|
203
|
+
if allowed_types and node["type"] not in allowed_types:
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
filtered_nodes.append(node)
|
|
207
|
+
|
|
208
|
+
# Sort by score (highest first) and return URLs
|
|
209
|
+
filtered_nodes.sort(key=lambda x: x["score"], reverse=True)
|
|
210
|
+
return [node["url"] for node in filtered_nodes]
|
|
211
|
+
|
|
212
|
+
def get_hive_nodes(
|
|
213
|
+
self, testnet: bool = False, not_working: bool = False, wss: bool = True, https: bool = True
|
|
214
|
+
) -> List[str]:
|
|
215
|
+
"""Return a list of Hive node URLs filtered and ordered by score.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
testnet: Include testnet nodes (default: False)
|
|
219
|
+
not_working: Include nodes with negative scores (default: False)
|
|
220
|
+
wss: Include WebSocket nodes (default: True)
|
|
221
|
+
https: Include HTTPS nodes (default: True)
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
List of Hive node URLs sorted by score
|
|
225
|
+
"""
|
|
226
|
+
return self.get_nodes(
|
|
227
|
+
hive=True,
|
|
228
|
+
testnet=testnet,
|
|
229
|
+
not_working=not_working,
|
|
230
|
+
wss=wss,
|
|
231
|
+
https=https,
|
|
232
|
+
appbase=True,
|
|
233
|
+
normal=False,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
def get_testnet(self, testnet: bool = True, testnetdev: bool = False) -> List[str]:
|
|
237
|
+
"""Return a list of testnet node URLs (currently unavailable).
|
|
238
|
+
|
|
239
|
+
Note: The PeakD beacon API does not provide testnet nodes. This method
|
|
240
|
+
currently returns an empty list. Use mainnet nodes for testing or
|
|
241
|
+
manually configure testnet endpoints.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
testnet: Include testnet nodes (default: True)
|
|
245
|
+
testnetdev: Include testnet dev nodes (default: False)
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
List of testnet node URLs
|
|
249
|
+
"""
|
|
250
|
+
log.warning("Testnet nodes are not available from beacon API")
|
|
251
|
+
return self.get_nodes(
|
|
252
|
+
normal=False,
|
|
253
|
+
appbase=False,
|
|
254
|
+
testnet=testnet,
|
|
255
|
+
testnetdev=testnetdev,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
def update_nodes(self, weights: Any = None, blockchain_instance: Any = None) -> None:
|
|
259
|
+
"""Refresh nodes from beacon API.
|
|
260
|
+
|
|
261
|
+
This method replaces the complex update logic with a simple refresh
|
|
262
|
+
from the beacon API and clears the cache to force fresh data.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
weights: Ignored (beacon provides its own scoring)
|
|
266
|
+
blockchain_instance: Ignored (beacon API is independent)
|
|
267
|
+
"""
|
|
268
|
+
clear_beacon_cache()
|
|
269
|
+
self._refresh_nodes()
|
|
270
|
+
|
|
271
|
+
def update(self, node_list: List[str]) -> None:
|
|
272
|
+
"""Update node list (not implemented with beacon API).
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
node_list: List of node URLs (ignored with beacon API)
|
|
276
|
+
"""
|
|
277
|
+
log.info("NodeList.update() is deprecated with beacon API - using beacon data instead")
|
|
278
|
+
self._refresh_nodes()
|
|
279
|
+
|
|
280
|
+
def get_node_answer_time(
|
|
281
|
+
self, node_list: Optional[List[str]] = None, verbose: bool = False
|
|
282
|
+
) -> List[Dict[str, float]]:
|
|
283
|
+
"""Get node response times (deprecated with beacon API).
|
|
284
|
+
|
|
285
|
+
The beacon API already provides performance scoring, so this method
|
|
286
|
+
returns the beacon scores instead of measuring response times.
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
node_list: List of node URLs to check (ignored, uses all nodes)
|
|
290
|
+
verbose: Log node information (default: False)
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
List of dictionaries with 'url' and 'delay_ms' keys
|
|
294
|
+
"""
|
|
295
|
+
log.info("get_node_answer_time() using beacon scores instead of measuring response times")
|
|
296
|
+
|
|
297
|
+
result = []
|
|
298
|
+
for node in self:
|
|
299
|
+
# Convert beacon score (0-100) to a fake delay for compatibility
|
|
300
|
+
# Higher score = lower delay
|
|
301
|
+
fake_delay_ms = (100 - node["score"]) * 10 # Simple conversion
|
|
302
|
+
result.append({"url": node["url"], "delay_ms": fake_delay_ms})
|
|
303
|
+
|
|
304
|
+
if verbose:
|
|
305
|
+
log.info(
|
|
306
|
+
f"node {node['url']} beacon score: {node['score']}, fake delay: {fake_delay_ms:.2f}ms"
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
return sorted(result, key=lambda x: x["delay_ms"])
|