playweb-node 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- playweb/__init__.py +18 -0
- playweb/api/__init__.py +4 -0
- playweb/api/erc20.py +66 -0
- playweb/api/erc721.py +88 -0
- playweb/api/metadata.py +86 -0
- playweb/api/node_api.py +224 -0
- playweb/api/public_api.py +496 -0
- playweb/api/rpc.py +247 -0
- playweb/client.py +341 -0
- playweb/config.py +168 -0
- playweb/consensus/__init__.py +5 -0
- playweb/consensus/leader.py +50 -0
- playweb/consensus/nvf_bft.py +540 -0
- playweb/consensus/vote.py +108 -0
- playweb/core/__init__.py +6 -0
- playweb/core/block.py +156 -0
- playweb/core/blockchain.py +358 -0
- playweb/core/fee_engine.py +312 -0
- playweb/core/mempool.py +112 -0
- playweb/core/royalty_engine.py +207 -0
- playweb/core/transaction.py +270 -0
- playweb/network/__init__.py +6 -0
- playweb/network/bootstrap.py +217 -0
- playweb/network/gossip.py +222 -0
- playweb/network/peer_manager.py +164 -0
- playweb/network/sync.py +244 -0
- playweb/node.py +303 -0
- playweb/plugin/__init__.py +4 -0
- playweb/plugin/base_plugin.py +225 -0
- playweb/plugin/plugin_manager.py +131 -0
- playweb/registry/__init__.py +4 -0
- playweb/registry/content_registry.py +259 -0
- playweb/registry/edition_registry.py +220 -0
- playweb/storage/__init__.py +11 -0
- playweb/storage/base.py +154 -0
- playweb/storage/ram_storage.py +123 -0
- playweb/storage/sqlite_storage.py +369 -0
- playweb/storage/supabase_storage.py +389 -0
- playweb_node-1.0.0.dist-info/METADATA +416 -0
- playweb_node-1.0.0.dist-info/RECORD +43 -0
- playweb_node-1.0.0.dist-info/WHEEL +5 -0
- playweb_node-1.0.0.dist-info/licenses/LICENSE +201 -0
- playweb_node-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PlayWebit Network — Transaction
|
|
3
|
+
Clean L1 transaction. No CipherVault logic. No Supabase.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import time
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Optional, Dict, Any
|
|
11
|
+
|
|
12
|
+
from playweb.config import CHAIN_ID, L1_TX_TYPES, AUTHORITY_TX_TYPES
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Transaction:
|
|
18
|
+
"""
|
|
19
|
+
L1 Transaction.
|
|
20
|
+
|
|
21
|
+
MANDATORY fields — L1 validates all of these:
|
|
22
|
+
from_addr, to_addr, amount, tx_type,
|
|
23
|
+
timestamp, signature, nonce, chain_id
|
|
24
|
+
|
|
25
|
+
CONDITIONAL fields — required for specific tx_types:
|
|
26
|
+
cid → required for content_register, cv_link,
|
|
27
|
+
ownership_transfer, edition_transfer
|
|
28
|
+
editions → required for content_register
|
|
29
|
+
royalty_pct → required for content_register
|
|
30
|
+
edition_number → required for edition_transfer
|
|
31
|
+
spider_hash → required for spider_hash_anchor
|
|
32
|
+
chain_name → required for spider_hash_anchor
|
|
33
|
+
|
|
34
|
+
OPTIONAL free field — L1 carries it, never reads it:
|
|
35
|
+
data: {} → platform stores anything here
|
|
36
|
+
license_type, song_title, platform_id,
|
|
37
|
+
spider_hash metadata, custom fields etc
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
from_addr: str,
|
|
43
|
+
to_addr: str,
|
|
44
|
+
amount: float,
|
|
45
|
+
tx_type: str,
|
|
46
|
+
signature: str = None,
|
|
47
|
+
nonce: int = None,
|
|
48
|
+
timestamp: float = None,
|
|
49
|
+
|
|
50
|
+
# Conditional fields
|
|
51
|
+
cid: Optional[str] = None,
|
|
52
|
+
editions: Optional[int] = None,
|
|
53
|
+
royalty_pct: Optional[float] = None,
|
|
54
|
+
edition_number: Optional[int] = None,
|
|
55
|
+
spider_hash: Optional[str] = None,
|
|
56
|
+
chain_name: Optional[str] = None,
|
|
57
|
+
|
|
58
|
+
# Free optional data field — L1 never reads this
|
|
59
|
+
data: Optional[Dict[str, Any]] = None,
|
|
60
|
+
):
|
|
61
|
+
# ── Mandatory fields ──────────────────────────────────
|
|
62
|
+
self.from_addr = from_addr.lower() if from_addr else from_addr
|
|
63
|
+
self.to_addr = to_addr.lower() if to_addr else to_addr
|
|
64
|
+
self.amount = float(amount)
|
|
65
|
+
self.tx_type = tx_type
|
|
66
|
+
self.timestamp = timestamp or time.time()
|
|
67
|
+
self.nonce = nonce if nonce is not None else int(self.timestamp * 1000)
|
|
68
|
+
self.chain_id = CHAIN_ID
|
|
69
|
+
self.signature = signature
|
|
70
|
+
|
|
71
|
+
# ── Conditional fields ────────────────────────────────
|
|
72
|
+
self.cid = cid
|
|
73
|
+
self.editions = editions
|
|
74
|
+
self.royalty_pct = royalty_pct
|
|
75
|
+
self.edition_number = edition_number
|
|
76
|
+
self.spider_hash = spider_hash
|
|
77
|
+
self.chain_name = chain_name
|
|
78
|
+
|
|
79
|
+
# ── Optional free data field ──────────────────────────
|
|
80
|
+
self.data = data or {}
|
|
81
|
+
|
|
82
|
+
# ── Computed ──────────────────────────────────────────
|
|
83
|
+
self.hash = self.calculate_hash()
|
|
84
|
+
|
|
85
|
+
# ─────────────────────────────────────────────────────────────
|
|
86
|
+
# Hashing
|
|
87
|
+
# ─────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
def get_signable_data(self) -> Dict:
|
|
90
|
+
"""
|
|
91
|
+
Returns the canonical dict that gets hashed and signed.
|
|
92
|
+
Only mandatory + conditional fields — not data{} field
|
|
93
|
+
because platforms may add data after signing.
|
|
94
|
+
"""
|
|
95
|
+
d = {
|
|
96
|
+
"from_addr": self.from_addr,
|
|
97
|
+
"to_addr": self.to_addr,
|
|
98
|
+
"amount": self.amount,
|
|
99
|
+
"tx_type": self.tx_type,
|
|
100
|
+
"timestamp": self.timestamp,
|
|
101
|
+
"nonce": self.nonce,
|
|
102
|
+
"chain_id": self.chain_id,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
# Include conditional fields only if set
|
|
106
|
+
if self.cid is not None: d["cid"] = self.cid
|
|
107
|
+
if self.editions is not None: d["editions"] = self.editions
|
|
108
|
+
if self.royalty_pct is not None: d["royalty_pct"] = self.royalty_pct
|
|
109
|
+
if self.edition_number is not None: d["edition_number"] = self.edition_number
|
|
110
|
+
if self.spider_hash is not None: d["spider_hash"] = self.spider_hash
|
|
111
|
+
if self.chain_name is not None: d["chain_name"] = self.chain_name
|
|
112
|
+
|
|
113
|
+
return d
|
|
114
|
+
|
|
115
|
+
def calculate_hash(self) -> str:
|
|
116
|
+
"""SHA256 of canonical signable data."""
|
|
117
|
+
raw = json.dumps(self.get_signable_data(), sort_keys=True)
|
|
118
|
+
return hashlib.sha256(raw.encode()).hexdigest()
|
|
119
|
+
|
|
120
|
+
# ─────────────────────────────────────────────────────────────
|
|
121
|
+
# Validation
|
|
122
|
+
# ─────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
def validate_fields(self) -> tuple[bool, str]:
|
|
125
|
+
"""
|
|
126
|
+
Validate mandatory and conditional fields.
|
|
127
|
+
Returns (is_valid, reason).
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
# Mandatory checks
|
|
131
|
+
if not self.from_addr:
|
|
132
|
+
return False, "Missing from_addr"
|
|
133
|
+
if not self.to_addr:
|
|
134
|
+
return False, "Missing to_addr"
|
|
135
|
+
if self.amount < 0:
|
|
136
|
+
return False, "Amount cannot be negative"
|
|
137
|
+
if self.tx_type not in L1_TX_TYPES:
|
|
138
|
+
return False, f"Unknown tx_type: {self.tx_type}"
|
|
139
|
+
if self.chain_id != CHAIN_ID:
|
|
140
|
+
return False, f"Wrong chain_id: {self.chain_id}, expected {CHAIN_ID}"
|
|
141
|
+
if self.nonce is None:
|
|
142
|
+
return False, "Missing nonce"
|
|
143
|
+
|
|
144
|
+
# Conditional checks
|
|
145
|
+
if self.tx_type == "content_register":
|
|
146
|
+
if not self.cid:
|
|
147
|
+
return False, "content_register requires cid"
|
|
148
|
+
if self.editions is None:
|
|
149
|
+
return False, "content_register requires editions"
|
|
150
|
+
if self.royalty_pct is None:
|
|
151
|
+
return False, "content_register requires royalty_pct"
|
|
152
|
+
if not (0 <= self.royalty_pct <= 100):
|
|
153
|
+
return False, "royalty_pct must be 0-100"
|
|
154
|
+
|
|
155
|
+
if self.tx_type in ("cv_link", "ownership_transfer"):
|
|
156
|
+
if not self.cid:
|
|
157
|
+
return False, f"{self.tx_type} requires cid"
|
|
158
|
+
|
|
159
|
+
if self.tx_type == "edition_transfer":
|
|
160
|
+
if not self.cid:
|
|
161
|
+
return False, "edition_transfer requires cid"
|
|
162
|
+
if self.edition_number is None:
|
|
163
|
+
return False, "edition_transfer requires edition_number"
|
|
164
|
+
|
|
165
|
+
if self.tx_type == "spider_hash_anchor":
|
|
166
|
+
if not self.spider_hash:
|
|
167
|
+
return False, "spider_hash_anchor requires spider_hash"
|
|
168
|
+
if not self.chain_name:
|
|
169
|
+
return False, "spider_hash_anchor requires chain_name"
|
|
170
|
+
|
|
171
|
+
return True, "Valid"
|
|
172
|
+
|
|
173
|
+
def verify_signature(self, public_key: str = None) -> bool:
|
|
174
|
+
"""
|
|
175
|
+
Verify the transaction signature.
|
|
176
|
+
Authority transactions skip verification.
|
|
177
|
+
MetaMask personal_sign compatible.
|
|
178
|
+
"""
|
|
179
|
+
# Authority txs don't need signature verification
|
|
180
|
+
if self.tx_type in AUTHORITY_TX_TYPES:
|
|
181
|
+
return True
|
|
182
|
+
|
|
183
|
+
if not self.signature:
|
|
184
|
+
logger.warning(f"Transaction {self.hash} missing signature")
|
|
185
|
+
return False
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
from eth_account import Account
|
|
189
|
+
from eth_account.messages import encode_defunct
|
|
190
|
+
|
|
191
|
+
signable = json.dumps(self.get_signable_data(), sort_keys=True)
|
|
192
|
+
message = encode_defunct(text=signable)
|
|
193
|
+
recovered = Account.recover_message(message, signature=self.signature)
|
|
194
|
+
|
|
195
|
+
return recovered.lower() == self.from_addr.lower()
|
|
196
|
+
|
|
197
|
+
except ImportError:
|
|
198
|
+
# eth_account not installed — basic format check only
|
|
199
|
+
logger.warning(
|
|
200
|
+
"eth_account not installed. "
|
|
201
|
+
"Skipping full signature verification. "
|
|
202
|
+
"Install: pip install eth-account"
|
|
203
|
+
)
|
|
204
|
+
return bool(self.signature and self.signature.startswith("0x"))
|
|
205
|
+
|
|
206
|
+
except Exception as e:
|
|
207
|
+
logger.error(f"Signature verification failed: {e}")
|
|
208
|
+
return False
|
|
209
|
+
|
|
210
|
+
# ─────────────────────────────────────────────────────────────
|
|
211
|
+
# Serialisation
|
|
212
|
+
# ─────────────────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
def to_dict(self) -> Dict:
|
|
215
|
+
d = {
|
|
216
|
+
# Mandatory
|
|
217
|
+
"hash": self.hash,
|
|
218
|
+
"from_addr": self.from_addr,
|
|
219
|
+
"to_addr": self.to_addr,
|
|
220
|
+
"amount": self.amount,
|
|
221
|
+
"tx_type": self.tx_type,
|
|
222
|
+
"timestamp": self.timestamp,
|
|
223
|
+
"nonce": self.nonce,
|
|
224
|
+
"chain_id": self.chain_id,
|
|
225
|
+
"signature": self.signature,
|
|
226
|
+
# Optional free field
|
|
227
|
+
"data": self.data,
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
# Conditional — only include if set
|
|
231
|
+
if self.cid is not None: d["cid"] = self.cid
|
|
232
|
+
if self.editions is not None: d["editions"] = self.editions
|
|
233
|
+
if self.royalty_pct is not None: d["royalty_pct"] = self.royalty_pct
|
|
234
|
+
if self.edition_number is not None: d["edition_number"] = self.edition_number
|
|
235
|
+
if self.spider_hash is not None: d["spider_hash"] = self.spider_hash
|
|
236
|
+
if self.chain_name is not None: d["chain_name"] = self.chain_name
|
|
237
|
+
|
|
238
|
+
return d
|
|
239
|
+
|
|
240
|
+
@classmethod
|
|
241
|
+
def from_dict(cls, d: Dict) -> "Transaction":
|
|
242
|
+
tx = cls(
|
|
243
|
+
from_addr = d["from_addr"],
|
|
244
|
+
to_addr = d["to_addr"],
|
|
245
|
+
amount = d["amount"],
|
|
246
|
+
tx_type = d["tx_type"],
|
|
247
|
+
signature = d.get("signature"),
|
|
248
|
+
nonce = d.get("nonce"),
|
|
249
|
+
timestamp = d.get("timestamp"),
|
|
250
|
+
cid = d.get("cid"),
|
|
251
|
+
editions = d.get("editions"),
|
|
252
|
+
royalty_pct = d.get("royalty_pct"),
|
|
253
|
+
edition_number = d.get("edition_number"),
|
|
254
|
+
spider_hash = d.get("spider_hash"),
|
|
255
|
+
chain_name = d.get("chain_name"),
|
|
256
|
+
data = d.get("data", {}),
|
|
257
|
+
)
|
|
258
|
+
# Restore original hash (don't recalculate — it was stored)
|
|
259
|
+
if "hash" in d:
|
|
260
|
+
tx.hash = d["hash"]
|
|
261
|
+
return tx
|
|
262
|
+
|
|
263
|
+
def __repr__(self):
|
|
264
|
+
return (
|
|
265
|
+
f"Transaction(type={self.tx_type}, "
|
|
266
|
+
f"from={self.from_addr[:8]}..., "
|
|
267
|
+
f"to={self.to_addr[:8]}..., "
|
|
268
|
+
f"amount={self.amount}, "
|
|
269
|
+
f"hash={self.hash[:12]}...)"
|
|
270
|
+
)
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
from playweb.network.peer_manager import PeerManager, Peer
|
|
2
|
+
from playweb.network.bootstrap import Bootstrap
|
|
3
|
+
from playweb.network.gossip import GossipProtocol
|
|
4
|
+
from playweb.network.sync import ChainSync
|
|
5
|
+
|
|
6
|
+
__all__ = ["PeerManager", "Peer", "Bootstrap", "GossipProtocol", "ChainSync"]
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PlayWebit Network — Bootstrap
|
|
3
|
+
First peer discovery via Cloudflare Worker.
|
|
4
|
+
After first connection, pure P2P gossip takes over.
|
|
5
|
+
Cloudflare is never needed again until node restarts.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import time
|
|
9
|
+
import json
|
|
10
|
+
import hashlib
|
|
11
|
+
import logging
|
|
12
|
+
import threading
|
|
13
|
+
import requests
|
|
14
|
+
from typing import List, Dict, Optional
|
|
15
|
+
|
|
16
|
+
from playweb.config import (
|
|
17
|
+
BOOTSTRAP_URL,
|
|
18
|
+
BOOTSTRAP_ENDPOINTS,
|
|
19
|
+
BOOTSTRAP_FALLBACK_NODES,
|
|
20
|
+
BOOTSTRAP_HEARTBEAT_INTERVAL,
|
|
21
|
+
PEER_TIMEOUT,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Bootstrap:
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
node_url: str,
|
|
32
|
+
node_wallet: str,
|
|
33
|
+
node_private_key: str,
|
|
34
|
+
platform: str = "unknown",
|
|
35
|
+
role: str = "validator",
|
|
36
|
+
):
|
|
37
|
+
self.node_url = node_url
|
|
38
|
+
self.node_wallet = node_wallet.lower()
|
|
39
|
+
self.node_private_key = node_private_key
|
|
40
|
+
self.platform = platform
|
|
41
|
+
self.role = role
|
|
42
|
+
self._heartbeat_thread: Optional[threading.Thread] = None
|
|
43
|
+
self._running = False
|
|
44
|
+
|
|
45
|
+
# ─────────────────────────────────────────────────────────────
|
|
46
|
+
# Signature for Cloudflare verification
|
|
47
|
+
# ─────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
def _sign(self, timestamp: int) -> str:
|
|
50
|
+
"""
|
|
51
|
+
Sign the bootstrap message with node's wallet.
|
|
52
|
+
message = "playwebit-bootstrap:{url}:{timestamp}"
|
|
53
|
+
"""
|
|
54
|
+
try:
|
|
55
|
+
from eth_account import Account
|
|
56
|
+
from eth_account.messages import encode_defunct
|
|
57
|
+
|
|
58
|
+
message = f"playwebit-bootstrap:{self.node_url}:{timestamp}"
|
|
59
|
+
msg = encode_defunct(text=message)
|
|
60
|
+
signed = Account.sign_message(msg, private_key=self.node_private_key)
|
|
61
|
+
sig = signed.signature.hex()
|
|
62
|
+
# ensure 0x prefix — Worker requires it
|
|
63
|
+
return sig if sig.startswith("0x") else "0x" + sig
|
|
64
|
+
except ImportError:
|
|
65
|
+
# Fallback placeholder signature (format-valid)
|
|
66
|
+
raw = f"{self.node_url}:{timestamp}:{self.node_wallet}"
|
|
67
|
+
return "0x" + hashlib.sha256(raw.encode()).hexdigest() + "0" * 66
|
|
68
|
+
except Exception as e:
|
|
69
|
+
logger.error(f"Bootstrap signing failed: {e}")
|
|
70
|
+
return "0x" + "0" * 130
|
|
71
|
+
|
|
72
|
+
# ─────────────────────────────────────────────────────────────
|
|
73
|
+
# Discover peers
|
|
74
|
+
# ─────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
def discover_peers(self) -> List[Dict]:
|
|
77
|
+
"""
|
|
78
|
+
Get list of active nodes from Cloudflare Worker.
|
|
79
|
+
Falls back to hardcoded nodes if Cloudflare unreachable.
|
|
80
|
+
Returns list of { url, wallet, platform } dicts.
|
|
81
|
+
"""
|
|
82
|
+
url = BOOTSTRAP_URL + BOOTSTRAP_ENDPOINTS["nodes"]
|
|
83
|
+
try:
|
|
84
|
+
res = requests.get(url, timeout=PEER_TIMEOUT)
|
|
85
|
+
if res.status_code == 200:
|
|
86
|
+
data = res.json()
|
|
87
|
+
nodes = data.get("nodes", [])
|
|
88
|
+
# Filter out self
|
|
89
|
+
nodes = [
|
|
90
|
+
n for n in nodes
|
|
91
|
+
if n.get("url") != self.node_url
|
|
92
|
+
and n.get("wallet", "").lower() != self.node_wallet
|
|
93
|
+
]
|
|
94
|
+
logger.info(
|
|
95
|
+
f"Bootstrap: discovered {len(nodes)} peers "
|
|
96
|
+
f"from Cloudflare"
|
|
97
|
+
)
|
|
98
|
+
return nodes
|
|
99
|
+
else:
|
|
100
|
+
logger.warning(
|
|
101
|
+
f"Bootstrap Cloudflare returned {res.status_code}"
|
|
102
|
+
)
|
|
103
|
+
except Exception as e:
|
|
104
|
+
logger.warning(f"Bootstrap Cloudflare unreachable: {e}")
|
|
105
|
+
|
|
106
|
+
# Fallback to hardcoded nodes
|
|
107
|
+
logger.info("Bootstrap: using fallback nodes")
|
|
108
|
+
return [
|
|
109
|
+
{"url": url, "wallet": "unknown", "platform": "playwebit"}
|
|
110
|
+
for url in BOOTSTRAP_FALLBACK_NODES
|
|
111
|
+
if url != self.node_url
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
# ─────────────────────────────────────────────────────────────
|
|
115
|
+
# Register node
|
|
116
|
+
# ─────────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
def register_node(self) -> bool:
|
|
119
|
+
"""
|
|
120
|
+
Register this node with the Cloudflare bootstrap directory.
|
|
121
|
+
Cloudflare will notify all existing nodes about us.
|
|
122
|
+
"""
|
|
123
|
+
timestamp = int(time.time())
|
|
124
|
+
signature = self._sign(timestamp)
|
|
125
|
+
url = BOOTSTRAP_URL + BOOTSTRAP_ENDPOINTS["register"]
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
res = requests.post(url, json={
|
|
129
|
+
"url": self.node_url,
|
|
130
|
+
"wallet": self.node_wallet,
|
|
131
|
+
"signature": signature,
|
|
132
|
+
"timestamp": timestamp,
|
|
133
|
+
"platform": self.platform,
|
|
134
|
+
"role": self.role,
|
|
135
|
+
}, timeout=PEER_TIMEOUT)
|
|
136
|
+
|
|
137
|
+
if res.status_code == 200:
|
|
138
|
+
data = res.json()
|
|
139
|
+
logger.info(
|
|
140
|
+
f"Bootstrap: node registered. "
|
|
141
|
+
f"Network has {data.get('node_count', '?')} nodes"
|
|
142
|
+
)
|
|
143
|
+
# Cloudflare returns peer list in registration response
|
|
144
|
+
# — use it to seed peer manager immediately
|
|
145
|
+
return True
|
|
146
|
+
else:
|
|
147
|
+
logger.warning(
|
|
148
|
+
f"Bootstrap registration failed: "
|
|
149
|
+
f"{res.status_code} {res.text}"
|
|
150
|
+
)
|
|
151
|
+
return False
|
|
152
|
+
|
|
153
|
+
except Exception as e:
|
|
154
|
+
logger.warning(f"Bootstrap registration error: {e}")
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
# ─────────────────────────────────────────────────────────────
|
|
158
|
+
# Heartbeat — keeps node listed in Cloudflare KV
|
|
159
|
+
# ─────────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
def start_heartbeat(self):
|
|
162
|
+
"""Start background heartbeat thread (every 12 hours)."""
|
|
163
|
+
self._running = True
|
|
164
|
+
self._heartbeat_thread = threading.Thread(
|
|
165
|
+
target = self._heartbeat_loop,
|
|
166
|
+
daemon = True,
|
|
167
|
+
name = "bootstrap-heartbeat",
|
|
168
|
+
)
|
|
169
|
+
self._heartbeat_thread.start()
|
|
170
|
+
logger.info("Bootstrap heartbeat started (every 12h)")
|
|
171
|
+
|
|
172
|
+
def stop_heartbeat(self):
|
|
173
|
+
self._running = False
|
|
174
|
+
|
|
175
|
+
def _heartbeat_loop(self):
|
|
176
|
+
while self._running:
|
|
177
|
+
time.sleep(BOOTSTRAP_HEARTBEAT_INTERVAL)
|
|
178
|
+
self._send_heartbeat()
|
|
179
|
+
|
|
180
|
+
def _send_heartbeat(self):
|
|
181
|
+
timestamp = int(time.time())
|
|
182
|
+
signature = self._sign(timestamp)
|
|
183
|
+
url = BOOTSTRAP_URL + BOOTSTRAP_ENDPOINTS["heartbeat"]
|
|
184
|
+
try:
|
|
185
|
+
res = requests.post(url, json={
|
|
186
|
+
"url": self.node_url,
|
|
187
|
+
"wallet": self.node_wallet,
|
|
188
|
+
"signature": signature,
|
|
189
|
+
"timestamp": timestamp,
|
|
190
|
+
}, timeout=PEER_TIMEOUT)
|
|
191
|
+
if res.status_code == 200:
|
|
192
|
+
logger.debug("Bootstrap heartbeat sent")
|
|
193
|
+
else:
|
|
194
|
+
logger.warning(f"Bootstrap heartbeat failed: {res.status_code}")
|
|
195
|
+
except Exception as e:
|
|
196
|
+
logger.warning(f"Bootstrap heartbeat error: {e}")
|
|
197
|
+
|
|
198
|
+
# ─────────────────────────────────────────────────────────────
|
|
199
|
+
# Deregister
|
|
200
|
+
# ─────────────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
def deregister(self):
|
|
203
|
+
"""Clean removal when node shuts down gracefully."""
|
|
204
|
+
self.stop_heartbeat()
|
|
205
|
+
timestamp = int(time.time())
|
|
206
|
+
signature = self._sign(timestamp)
|
|
207
|
+
url = BOOTSTRAP_URL + BOOTSTRAP_ENDPOINTS["deregister"]
|
|
208
|
+
try:
|
|
209
|
+
requests.post(url, json={
|
|
210
|
+
"url": self.node_url,
|
|
211
|
+
"wallet": self.node_wallet,
|
|
212
|
+
"signature": signature,
|
|
213
|
+
"timestamp": timestamp,
|
|
214
|
+
}, timeout=PEER_TIMEOUT)
|
|
215
|
+
logger.info("Bootstrap: node deregistered")
|
|
216
|
+
except Exception as e:
|
|
217
|
+
logger.warning(f"Bootstrap deregistration error: {e}")
|