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,389 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PlayWebit Network — Supabase Storage
|
|
3
|
+
For nodes running on HuggingFace or anywhere without persistent disk.
|
|
4
|
+
Uses Supabase as the persistent layer.
|
|
5
|
+
|
|
6
|
+
Required Supabase tables (run this SQL in your Supabase SQL editor):
|
|
7
|
+
|
|
8
|
+
CREATE TABLE IF NOT EXISTS pw_blocks (
|
|
9
|
+
hash TEXT PRIMARY KEY,
|
|
10
|
+
idx INTEGER UNIQUE,
|
|
11
|
+
previous_hash TEXT,
|
|
12
|
+
merkle_root TEXT,
|
|
13
|
+
timestamp FLOAT,
|
|
14
|
+
nonce INTEGER DEFAULT 0,
|
|
15
|
+
validator_wallet TEXT,
|
|
16
|
+
consensus_round INTEGER DEFAULT 0,
|
|
17
|
+
votes JSONB DEFAULT '[]',
|
|
18
|
+
transactions JSONB DEFAULT '[]'
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
CREATE TABLE IF NOT EXISTS pw_transactions (
|
|
22
|
+
hash TEXT PRIMARY KEY,
|
|
23
|
+
data JSONB
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
CREATE TABLE IF NOT EXISTS pw_balances (
|
|
27
|
+
address TEXT PRIMARY KEY,
|
|
28
|
+
balance FLOAT DEFAULT 0.0,
|
|
29
|
+
updated FLOAT
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
CREATE TABLE IF NOT EXISTS pw_content_registry (
|
|
33
|
+
cid TEXT PRIMARY KEY,
|
|
34
|
+
creator_wallet TEXT,
|
|
35
|
+
first_owner TEXT,
|
|
36
|
+
current_owner TEXT,
|
|
37
|
+
first_platform TEXT,
|
|
38
|
+
first_tx_hash TEXT,
|
|
39
|
+
first_block INTEGER,
|
|
40
|
+
timestamp FLOAT,
|
|
41
|
+
total_editions INTEGER DEFAULT 1,
|
|
42
|
+
royalty_pct FLOAT DEFAULT 0,
|
|
43
|
+
extra JSONB DEFAULT '{}'
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
CREATE TABLE IF NOT EXISTS pw_edition_registry (
|
|
47
|
+
cid TEXT,
|
|
48
|
+
edition_number INTEGER,
|
|
49
|
+
edition_of INTEGER DEFAULT 1,
|
|
50
|
+
current_owner TEXT,
|
|
51
|
+
platform TEXT,
|
|
52
|
+
tx_hash TEXT,
|
|
53
|
+
timestamp FLOAT,
|
|
54
|
+
provenance JSONB DEFAULT '[]',
|
|
55
|
+
PRIMARY KEY (cid, edition_number)
|
|
56
|
+
);
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
import json
|
|
60
|
+
import time
|
|
61
|
+
import logging
|
|
62
|
+
from typing import List, Optional, Dict
|
|
63
|
+
|
|
64
|
+
from playweb.storage.base import ChainStorage
|
|
65
|
+
|
|
66
|
+
logger = logging.getLogger(__name__)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class SupabaseStorage(ChainStorage):
|
|
70
|
+
|
|
71
|
+
def __init__(self, url: str, key: str):
|
|
72
|
+
try:
|
|
73
|
+
from supabase import create_client, ClientOptions
|
|
74
|
+
import httpx
|
|
75
|
+
|
|
76
|
+
# HTTP/2 is the supabase-py default, but idle HTTP/2 streams get
|
|
77
|
+
# cleanly terminated by Supabase's edge on hosts like HF Spaces,
|
|
78
|
+
# surfacing as ConnectionTerminated / "Server disconnected" on
|
|
79
|
+
# the next reused request. Force HTTP/1.1 and recycle idle
|
|
80
|
+
# connections before the remote does it for us.
|
|
81
|
+
httpx_client = httpx.Client(
|
|
82
|
+
http2=False,
|
|
83
|
+
timeout=httpx.Timeout(connect=10.0, read=30.0, write=30.0, pool=10.0),
|
|
84
|
+
limits=httpx.Limits(
|
|
85
|
+
max_connections=20,
|
|
86
|
+
max_keepalive_connections=5,
|
|
87
|
+
keepalive_expiry=15.0,
|
|
88
|
+
),
|
|
89
|
+
)
|
|
90
|
+
options = ClientOptions(httpx_client=httpx_client)
|
|
91
|
+
self.sb = create_client(url, key, options=options)
|
|
92
|
+
logger.info("SupabaseStorage initialised (HTTP/1.1, keepalive_expiry=15s)")
|
|
93
|
+
except ImportError:
|
|
94
|
+
raise ImportError(
|
|
95
|
+
"supabase package not installed. "
|
|
96
|
+
"Run: pip install supabase"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def _t(self, name: str):
|
|
100
|
+
"""Shorthand for table access."""
|
|
101
|
+
return self.sb.table(name)
|
|
102
|
+
|
|
103
|
+
# ─── Blocks ──────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
def save_block(self, block) -> bool:
|
|
106
|
+
try:
|
|
107
|
+
data = {
|
|
108
|
+
"hash": block.hash,
|
|
109
|
+
"idx": block.index,
|
|
110
|
+
"previous_hash": block.previous_hash,
|
|
111
|
+
"merkle_root": block.merkle_root,
|
|
112
|
+
"timestamp": block.timestamp,
|
|
113
|
+
"nonce": block.nonce,
|
|
114
|
+
"validator_wallet": block.validator_wallet,
|
|
115
|
+
"consensus_round": block.consensus_round,
|
|
116
|
+
"votes": block.votes,
|
|
117
|
+
"transactions": [tx.to_dict() for tx in block.transactions],
|
|
118
|
+
}
|
|
119
|
+
self._t("pw_blocks").upsert(data).execute()
|
|
120
|
+
return True
|
|
121
|
+
except Exception as e:
|
|
122
|
+
logger.error(f"save_block error: {e}")
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
def get_block(self, block_hash: str):
|
|
126
|
+
try:
|
|
127
|
+
res = self._t("pw_blocks").select("*").eq("hash", block_hash).execute()
|
|
128
|
+
return self._row_to_block(res.data[0]) if res.data else None
|
|
129
|
+
except Exception as e:
|
|
130
|
+
logger.error(f"get_block error: {e}")
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
def get_block_by_index(self, index: int):
|
|
134
|
+
try:
|
|
135
|
+
res = self._t("pw_blocks").select("*").eq("idx", index).execute()
|
|
136
|
+
return self._row_to_block(res.data[0]) if res.data else None
|
|
137
|
+
except Exception as e:
|
|
138
|
+
logger.error(f"get_block_by_index error: {e}")
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
def get_chain_tip(self):
|
|
142
|
+
try:
|
|
143
|
+
res = (
|
|
144
|
+
self._t("pw_blocks")
|
|
145
|
+
.select("*")
|
|
146
|
+
.order("idx", desc=True)
|
|
147
|
+
.limit(1)
|
|
148
|
+
.execute()
|
|
149
|
+
)
|
|
150
|
+
return self._row_to_block(res.data[0]) if res.data else None
|
|
151
|
+
except Exception as e:
|
|
152
|
+
logger.error(f"get_chain_tip error: {e}")
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
def get_chain_length(self) -> int:
|
|
156
|
+
try:
|
|
157
|
+
res = self._t("pw_blocks").select("idx", count="exact").execute()
|
|
158
|
+
return res.count or 0
|
|
159
|
+
except Exception as e:
|
|
160
|
+
logger.error(f"get_chain_length error: {e}")
|
|
161
|
+
return 0
|
|
162
|
+
|
|
163
|
+
def get_blocks_from(self, from_index: int, limit: int = 50) -> List:
|
|
164
|
+
try:
|
|
165
|
+
res = (
|
|
166
|
+
self._t("pw_blocks")
|
|
167
|
+
.select("*")
|
|
168
|
+
.gte("idx", from_index)
|
|
169
|
+
.order("idx", desc=False)
|
|
170
|
+
.limit(limit)
|
|
171
|
+
.execute()
|
|
172
|
+
)
|
|
173
|
+
return [self._row_to_block(r) for r in (res.data or [])]
|
|
174
|
+
except Exception as e:
|
|
175
|
+
logger.error(f"get_blocks_from error: {e}")
|
|
176
|
+
return []
|
|
177
|
+
|
|
178
|
+
def _row_to_block(self, row: Dict):
|
|
179
|
+
from playweb.core.block import Block
|
|
180
|
+
from playweb.core.transaction import Transaction
|
|
181
|
+
|
|
182
|
+
txs_data = row.get("transactions", [])
|
|
183
|
+
if isinstance(txs_data, str):
|
|
184
|
+
txs_data = json.loads(txs_data)
|
|
185
|
+
|
|
186
|
+
txs = [Transaction.from_dict(t) for t in txs_data]
|
|
187
|
+
votes = row.get("votes", [])
|
|
188
|
+
if isinstance(votes, str):
|
|
189
|
+
votes = json.loads(votes)
|
|
190
|
+
|
|
191
|
+
block = Block(
|
|
192
|
+
index = row["idx"],
|
|
193
|
+
transactions = txs,
|
|
194
|
+
previous_hash = row["previous_hash"],
|
|
195
|
+
validator_wallet = row["validator_wallet"],
|
|
196
|
+
timestamp = row["timestamp"],
|
|
197
|
+
nonce = row.get("nonce", 0),
|
|
198
|
+
consensus_round = row.get("consensus_round", 0),
|
|
199
|
+
votes = votes,
|
|
200
|
+
)
|
|
201
|
+
block.hash = row["hash"]
|
|
202
|
+
block.merkle_root = row["merkle_root"]
|
|
203
|
+
return block
|
|
204
|
+
|
|
205
|
+
# ─── Transactions ─────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
def save_transaction(self, tx) -> bool:
|
|
208
|
+
try:
|
|
209
|
+
self._t("pw_transactions").upsert({
|
|
210
|
+
"hash": tx.hash,
|
|
211
|
+
"data": tx.to_dict(),
|
|
212
|
+
}).execute()
|
|
213
|
+
return True
|
|
214
|
+
except Exception as e:
|
|
215
|
+
logger.error(f"save_transaction error: {e}")
|
|
216
|
+
return False
|
|
217
|
+
|
|
218
|
+
def get_transaction(self, tx_hash: str):
|
|
219
|
+
try:
|
|
220
|
+
res = (
|
|
221
|
+
self._t("pw_transactions")
|
|
222
|
+
.select("data")
|
|
223
|
+
.eq("hash", tx_hash)
|
|
224
|
+
.execute()
|
|
225
|
+
)
|
|
226
|
+
if not res.data:
|
|
227
|
+
return None
|
|
228
|
+
from playweb.core.transaction import Transaction
|
|
229
|
+
data = res.data[0]["data"]
|
|
230
|
+
if isinstance(data, str):
|
|
231
|
+
data = json.loads(data)
|
|
232
|
+
return Transaction.from_dict(data)
|
|
233
|
+
except Exception as e:
|
|
234
|
+
logger.error(f"get_transaction error: {e}")
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
# ─── Balances ────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
def get_balance(self, address: str) -> float:
|
|
240
|
+
try:
|
|
241
|
+
res = (
|
|
242
|
+
self._t("pw_balances")
|
|
243
|
+
.select("balance")
|
|
244
|
+
.eq("address", address.lower())
|
|
245
|
+
.execute()
|
|
246
|
+
)
|
|
247
|
+
return float(res.data[0]["balance"]) if res.data else 0.0
|
|
248
|
+
except Exception as e:
|
|
249
|
+
logger.error(f"get_balance error: {e}")
|
|
250
|
+
return 0.0
|
|
251
|
+
|
|
252
|
+
def save_balance(self, address: str, balance: float) -> bool:
|
|
253
|
+
try:
|
|
254
|
+
self._t("pw_balances").upsert({
|
|
255
|
+
"address": address.lower(),
|
|
256
|
+
"balance": max(0.0, balance),
|
|
257
|
+
"updated": time.time(),
|
|
258
|
+
}).execute()
|
|
259
|
+
return True
|
|
260
|
+
except Exception as e:
|
|
261
|
+
logger.error(f"save_balance error: {e}")
|
|
262
|
+
return False
|
|
263
|
+
|
|
264
|
+
def get_all_addresses(self) -> List[str]:
|
|
265
|
+
try:
|
|
266
|
+
res = self._t("pw_balances").select("address").execute()
|
|
267
|
+
return [r["address"] for r in (res.data or [])]
|
|
268
|
+
except Exception as e:
|
|
269
|
+
logger.error(f"get_all_addresses error: {e}")
|
|
270
|
+
return []
|
|
271
|
+
|
|
272
|
+
# ─── Content Registry ─────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
def save_content_record(self, record: Dict) -> bool:
|
|
275
|
+
try:
|
|
276
|
+
extra = {k: v for k, v in record.items() if k not in (
|
|
277
|
+
"cid", "creator_wallet", "first_owner", "current_owner",
|
|
278
|
+
"first_platform", "first_tx_hash", "first_block",
|
|
279
|
+
"timestamp", "total_editions", "royalty_pct"
|
|
280
|
+
)}
|
|
281
|
+
self._t("pw_content_registry").upsert({
|
|
282
|
+
"cid": record["cid"],
|
|
283
|
+
"creator_wallet": record.get("creator_wallet"),
|
|
284
|
+
"first_owner": record.get("first_owner"),
|
|
285
|
+
"current_owner": record.get("current_owner", record.get("first_owner", "")).lower(),
|
|
286
|
+
"first_platform": record.get("first_platform", "unknown"),
|
|
287
|
+
"first_tx_hash": record.get("first_tx_hash"),
|
|
288
|
+
"first_block": record.get("first_block"),
|
|
289
|
+
"timestamp": record.get("timestamp"),
|
|
290
|
+
"total_editions": record.get("total_editions", 1),
|
|
291
|
+
"royalty_pct": record.get("royalty_pct", 0),
|
|
292
|
+
"extra": extra,
|
|
293
|
+
}).execute()
|
|
294
|
+
return True
|
|
295
|
+
except Exception as e:
|
|
296
|
+
logger.error(f"save_content_record error: {e}")
|
|
297
|
+
return False
|
|
298
|
+
|
|
299
|
+
def get_content_record(self, cid: str) -> Optional[Dict]:
|
|
300
|
+
try:
|
|
301
|
+
res = (
|
|
302
|
+
self._t("pw_content_registry")
|
|
303
|
+
.select("*")
|
|
304
|
+
.eq("cid", cid)
|
|
305
|
+
.execute()
|
|
306
|
+
)
|
|
307
|
+
if not res.data:
|
|
308
|
+
return None
|
|
309
|
+
record = res.data[0]
|
|
310
|
+
extra = record.pop("extra", {}) or {}
|
|
311
|
+
if isinstance(extra, str):
|
|
312
|
+
extra = json.loads(extra)
|
|
313
|
+
record.update(extra)
|
|
314
|
+
return record
|
|
315
|
+
except Exception as e:
|
|
316
|
+
logger.error(f"get_content_record error: {e}")
|
|
317
|
+
return None
|
|
318
|
+
|
|
319
|
+
def get_all_content_by_owner(self, address: str) -> List[Dict]:
|
|
320
|
+
try:
|
|
321
|
+
res = (
|
|
322
|
+
self._t("pw_content_registry")
|
|
323
|
+
.select("*")
|
|
324
|
+
.eq("current_owner", address.lower())
|
|
325
|
+
.execute()
|
|
326
|
+
)
|
|
327
|
+
return res.data or []
|
|
328
|
+
except Exception as e:
|
|
329
|
+
logger.error(f"get_all_content_by_owner error: {e}")
|
|
330
|
+
return []
|
|
331
|
+
|
|
332
|
+
def get_cid_by_int_id(self, int_id: int) -> Optional[str]:
|
|
333
|
+
try:
|
|
334
|
+
from playweb.api.erc721 import cid_to_int_id
|
|
335
|
+
res = self._t("pw_content_registry").select("cid").execute()
|
|
336
|
+
for r in (res.data or []):
|
|
337
|
+
if cid_to_int_id(r["cid"]) == int_id:
|
|
338
|
+
return r["cid"]
|
|
339
|
+
return None
|
|
340
|
+
except Exception as e:
|
|
341
|
+
logger.error(f"get_cid_by_int_id error: {e}")
|
|
342
|
+
return None
|
|
343
|
+
|
|
344
|
+
# ─── Edition Registry ─────────────────────────────────────────
|
|
345
|
+
|
|
346
|
+
def save_edition_record(self, record: Dict) -> bool:
|
|
347
|
+
try:
|
|
348
|
+
self._t("pw_edition_registry").upsert({
|
|
349
|
+
"cid": record["cid"],
|
|
350
|
+
"edition_number": record["edition_number"],
|
|
351
|
+
"edition_of": record.get("edition_of", 1),
|
|
352
|
+
"current_owner": record.get("current_owner", "").lower(),
|
|
353
|
+
"platform": record.get("platform", "unknown"),
|
|
354
|
+
"tx_hash": record.get("tx_hash"),
|
|
355
|
+
"timestamp": record.get("timestamp"),
|
|
356
|
+
"provenance": record.get("provenance", []),
|
|
357
|
+
}).execute()
|
|
358
|
+
return True
|
|
359
|
+
except Exception as e:
|
|
360
|
+
logger.error(f"save_edition_record error: {e}")
|
|
361
|
+
return False
|
|
362
|
+
|
|
363
|
+
def get_edition_record(self, cid: str, edition_number: int) -> Optional[Dict]:
|
|
364
|
+
try:
|
|
365
|
+
res = (
|
|
366
|
+
self._t("pw_edition_registry")
|
|
367
|
+
.select("*")
|
|
368
|
+
.eq("cid", cid)
|
|
369
|
+
.eq("edition_number", edition_number)
|
|
370
|
+
.execute()
|
|
371
|
+
)
|
|
372
|
+
return res.data[0] if res.data else None
|
|
373
|
+
except Exception as e:
|
|
374
|
+
logger.error(f"get_edition_record error: {e}")
|
|
375
|
+
return None
|
|
376
|
+
|
|
377
|
+
def get_all_edition_records(self, cid: str) -> List[Dict]:
|
|
378
|
+
try:
|
|
379
|
+
res = (
|
|
380
|
+
self._t("pw_edition_registry")
|
|
381
|
+
.select("*")
|
|
382
|
+
.eq("cid", cid)
|
|
383
|
+
.order("edition_number")
|
|
384
|
+
.execute()
|
|
385
|
+
)
|
|
386
|
+
return res.data or []
|
|
387
|
+
except Exception as e:
|
|
388
|
+
logger.error(f"get_all_edition_records error: {e}")
|
|
389
|
+
return []
|