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.
Files changed (43) hide show
  1. playweb/__init__.py +18 -0
  2. playweb/api/__init__.py +4 -0
  3. playweb/api/erc20.py +66 -0
  4. playweb/api/erc721.py +88 -0
  5. playweb/api/metadata.py +86 -0
  6. playweb/api/node_api.py +224 -0
  7. playweb/api/public_api.py +496 -0
  8. playweb/api/rpc.py +247 -0
  9. playweb/client.py +341 -0
  10. playweb/config.py +168 -0
  11. playweb/consensus/__init__.py +5 -0
  12. playweb/consensus/leader.py +50 -0
  13. playweb/consensus/nvf_bft.py +540 -0
  14. playweb/consensus/vote.py +108 -0
  15. playweb/core/__init__.py +6 -0
  16. playweb/core/block.py +156 -0
  17. playweb/core/blockchain.py +358 -0
  18. playweb/core/fee_engine.py +312 -0
  19. playweb/core/mempool.py +112 -0
  20. playweb/core/royalty_engine.py +207 -0
  21. playweb/core/transaction.py +270 -0
  22. playweb/network/__init__.py +6 -0
  23. playweb/network/bootstrap.py +217 -0
  24. playweb/network/gossip.py +222 -0
  25. playweb/network/peer_manager.py +164 -0
  26. playweb/network/sync.py +244 -0
  27. playweb/node.py +303 -0
  28. playweb/plugin/__init__.py +4 -0
  29. playweb/plugin/base_plugin.py +225 -0
  30. playweb/plugin/plugin_manager.py +131 -0
  31. playweb/registry/__init__.py +4 -0
  32. playweb/registry/content_registry.py +259 -0
  33. playweb/registry/edition_registry.py +220 -0
  34. playweb/storage/__init__.py +11 -0
  35. playweb/storage/base.py +154 -0
  36. playweb/storage/ram_storage.py +123 -0
  37. playweb/storage/sqlite_storage.py +369 -0
  38. playweb/storage/supabase_storage.py +389 -0
  39. playweb_node-1.0.0.dist-info/METADATA +416 -0
  40. playweb_node-1.0.0.dist-info/RECORD +43 -0
  41. playweb_node-1.0.0.dist-info/WHEEL +5 -0
  42. playweb_node-1.0.0.dist-info/licenses/LICENSE +201 -0
  43. playweb_node-1.0.0.dist-info/top_level.txt +1 -0
playweb/node.py ADDED
@@ -0,0 +1,303 @@
1
+ """
2
+ PlayWebit Network — PlayWebitNode
3
+ Main entry point for running a full validator node.
4
+ Wires all components together.
5
+
6
+ Usage:
7
+ from playweb import PlayWebitNode
8
+ from playweb.storage.supabase_storage import SupabaseStorage
9
+
10
+ storage = SupabaseStorage(url=..., key=...)
11
+ node = PlayWebitNode(
12
+ storage = storage,
13
+ node_wallet = "0xYourWallet",
14
+ node_private_key = os.getenv("NODE_WALLET_PRIVATE_KEY"),
15
+ node_public_url = "https://your-node.com",
16
+ )
17
+ node.start()
18
+ """
19
+
20
+ import os
21
+ import time
22
+ import logging
23
+ import threading
24
+ from typing import Optional, TYPE_CHECKING
25
+
26
+ from playweb.core.blockchain import Blockchain
27
+ from playweb.core.fee_engine import FeeEngine
28
+ from playweb.core.royalty_engine import RoyaltyEngine
29
+ from playweb.consensus.nvf_bft import NVFBFTConsensus
30
+ from playweb.network.peer_manager import PeerManager
31
+ from playweb.network.bootstrap import Bootstrap
32
+ from playweb.network.gossip import GossipProtocol
33
+ from playweb.network.sync import ChainSync
34
+ from playweb.registry.content_registry import ContentRegistry
35
+ from playweb.registry.edition_registry import EditionRegistry
36
+ from playweb.plugin.plugin_manager import PluginManager
37
+ from playweb.config import LOG_LEVEL, NODE_PORT
38
+
39
+ if TYPE_CHECKING:
40
+ from playweb.plugin.base_plugin import BasePlugin
41
+ from playweb.storage.base import ChainStorage
42
+
43
+ logging.basicConfig(
44
+ level = getattr(logging, LOG_LEVEL, logging.INFO),
45
+ format = "%(asctime)s [%(name)s] %(levelname)s: %(message)s",
46
+ )
47
+ logger = logging.getLogger(__name__)
48
+
49
+
50
+ class PlayWebitNode:
51
+
52
+ def __init__(
53
+ self,
54
+ storage,
55
+ node_wallet: str,
56
+ node_private_key: str,
57
+ node_public_url: str,
58
+ platform: str = "unknown",
59
+ plugin: Optional["BasePlugin"] = None,
60
+ port: int = NODE_PORT,
61
+ ):
62
+ self.node_wallet = node_wallet.lower()
63
+ self.node_private_key = node_private_key
64
+ self.node_url = node_public_url.rstrip("/")
65
+ self.platform = platform
66
+ self.port = port
67
+
68
+ # ── Core ────────────────────────────────────────────────
69
+ self.blockchain = Blockchain(storage)
70
+
71
+ # ── Registries ──────────────────────────────────────────
72
+ self.content_registry = ContentRegistry(self.blockchain, self.node_wallet)
73
+ self.edition_registry = EditionRegistry(self.blockchain, self.node_wallet)
74
+
75
+ # ── Network ─────────────────────────────────────────────
76
+ self.peer_manager = PeerManager(self.node_url, self.node_wallet)
77
+ self.gossip = GossipProtocol(self.node_wallet)
78
+ self.bootstrap = Bootstrap(
79
+ node_url = self.node_url,
80
+ node_wallet = self.node_wallet,
81
+ node_private_key = self.node_private_key,
82
+ platform = self.platform,
83
+ )
84
+ self.sync = ChainSync(self.blockchain, self.peer_manager)
85
+
86
+ # ── Plugins ─────────────────────────────────────────────
87
+ self.plugin_manager = PluginManager()
88
+ self.plugin_manager.set_node(self)
89
+
90
+ # ── Consensus ───────────────────────────────────────────
91
+ self.consensus = NVFBFTConsensus(
92
+ blockchain = self.blockchain,
93
+ peer_manager = self.peer_manager,
94
+ gossip = self.gossip,
95
+ node_wallet = self.node_wallet,
96
+ node_private_key = self.node_private_key,
97
+ plugin_manager = self.plugin_manager,
98
+ on_block_finalised = self._on_block_finalised,
99
+ )
100
+
101
+ # Register plugin if provided
102
+ if plugin:
103
+ self.register_plugin(plugin)
104
+
105
+ self._flask_app = None
106
+ self._flask_thread: Optional[threading.Thread] = None
107
+ self._running = False
108
+
109
+ logger.info(
110
+ f"PlayWebitNode initialised: "
111
+ f"wallet={self.node_wallet[:12]}... "
112
+ f"url={self.node_url}"
113
+ )
114
+
115
+ # ─────────────────────────────────────────────────────────────
116
+ # Plugin registration
117
+ # ─────────────────────────────────────────────────────────────
118
+
119
+ def register_plugin(self, plugin: "BasePlugin"):
120
+ """Register a L2 plugin with this node."""
121
+ self.plugin_manager.register(plugin)
122
+
123
+ # ─────────────────────────────────────────────────────────────
124
+ # Start
125
+ # ─────────────────────────────────────────────────────────────
126
+
127
+ def start(self, blocking: bool = True):
128
+ """
129
+ Start the node:
130
+ 1. Discover peers via Cloudflare bootstrap
131
+ 2. Register self with bootstrap directory
132
+ 3. Sync chain from peers
133
+ 4. Start Flask API server
134
+ 5. Start NVF-BFT consensus loop
135
+ 6. Start heartbeat
136
+ 7. Notify plugins
137
+ """
138
+ self._running = True
139
+ logger.info("=" * 50)
140
+ logger.info("PlayWebit Network Node Starting...")
141
+ logger.info(f" Wallet: {self.node_wallet}")
142
+ logger.info(f" URL: {self.node_url}")
143
+ logger.info(f" Platform: {self.platform}")
144
+ logger.info("=" * 50)
145
+
146
+ # Step 1: Discover peers
147
+ logger.info("Step 1/6: Discovering peers via bootstrap...")
148
+ peers = self.bootstrap.discover_peers()
149
+ self.peer_manager.load_peers_from_list(peers)
150
+ logger.info(f" Found {len(peers)} peers")
151
+
152
+ # Step 2: Register with Cloudflare bootstrap
153
+ logger.info("Step 2/6: Registering with bootstrap directory...")
154
+ self.bootstrap.register_node()
155
+
156
+ # Step 3: Sync chain
157
+ logger.info("Step 3/6: Syncing chain from peers...")
158
+ self.sync.sync()
159
+ tip = self.blockchain.get_chain_tip()
160
+ logger.info(
161
+ f" Chain synced. Height: {tip.index if tip else 0}"
162
+ )
163
+
164
+ # Step 4: Start Flask API
165
+ logger.info(f"Step 4/6: Starting API server on port {self.port}...")
166
+ self._start_flask()
167
+
168
+ # Step 5: Start consensus
169
+ logger.info("Step 5/6: Starting NVF-BFT consensus...")
170
+ self.consensus.start()
171
+
172
+ # Step 6: Start heartbeat
173
+ logger.info("Step 6/6: Starting bootstrap heartbeat...")
174
+ self.bootstrap.start_heartbeat()
175
+
176
+ # Notify plugins
177
+ self.plugin_manager.notify_start(self)
178
+
179
+ logger.info("=" * 50)
180
+ logger.info("Node is live and participating in consensus!")
181
+ logger.info("=" * 50)
182
+
183
+ if blocking:
184
+ try:
185
+ while self._running:
186
+ time.sleep(1)
187
+ except KeyboardInterrupt:
188
+ self.stop()
189
+
190
+ def stop(self):
191
+ """Graceful shutdown."""
192
+ logger.info("Node shutting down...")
193
+ self._running = False
194
+ self.consensus.stop()
195
+ self.bootstrap.deregister()
196
+ self.plugin_manager.notify_stop()
197
+ logger.info("Node stopped.")
198
+
199
+ # ─────────────────────────────────────────────────────────────
200
+ # Flask API server
201
+ # ─────────────────────────────────────────────────────────────
202
+
203
+ def _start_flask(self):
204
+ """Start Flask in a background thread."""
205
+ from flask import Flask
206
+ from playweb.api.node_api import create_node_api
207
+ from playweb.api.public_api import create_public_api
208
+ from playweb.api.rpc import create_rpc
209
+ from playweb.api.metadata import create_metadata_api
210
+
211
+ app = Flask(__name__)
212
+ app.register_blueprint(create_node_api(self))
213
+ app.register_blueprint(create_public_api(self))
214
+ app.register_blueprint(create_rpc(self))
215
+ app.register_blueprint(create_metadata_api(self))
216
+
217
+ self._flask_app = app
218
+
219
+ self._flask_thread = threading.Thread(
220
+ target=lambda: app.run(
221
+ host = "0.0.0.0",
222
+ port = self.port,
223
+ debug = False,
224
+ use_reloader = False,
225
+ ),
226
+ daemon = True,
227
+ name = "flask-api",
228
+ )
229
+ self._flask_thread.start()
230
+ logger.info(f"API server started on port {self.port}")
231
+
232
+ def get_flask_app(self):
233
+ """
234
+ Get the Flask app for external mounting.
235
+ Use this if you want to add your own routes on top.
236
+ Returns the Flask app instance.
237
+ """
238
+ if not self._flask_app:
239
+ from flask import Flask
240
+ from playweb.api.node_api import create_node_api
241
+ from playweb.api.public_api import create_public_api
242
+
243
+ app = Flask(__name__)
244
+ app.register_blueprint(create_node_api(self))
245
+ app.register_blueprint(create_public_api(self))
246
+ self._flask_app = app
247
+
248
+ return self._flask_app
249
+
250
+ # ─────────────────────────────────────────────────────────────
251
+ # Consensus callback
252
+ # ─────────────────────────────────────────────────────────────
253
+
254
+ def _on_block_finalised(self, block, votes):
255
+ """
256
+ Called by consensus after a block is finalised.
257
+ Broadcasts to peers so they can add it to their chain.
258
+ NVF: Cyclic re-entry notification complete.
259
+ """
260
+ self.gossip.broadcast_finalised_block(
261
+ block = block,
262
+ votes = votes,
263
+ peers = self.peer_manager.get_active_peers(),
264
+ )
265
+
266
+ # ─────────────────────────────────────────────────────────────
267
+ # Convenience methods
268
+ # ─────────────────────────────────────────────────────────────
269
+
270
+ def submit_transaction(self, tx, broadcast: bool = True):
271
+ """Submit a transaction and optionally broadcast to peers."""
272
+ success, result = self.blockchain.add_transaction(
273
+ tx = tx,
274
+ node_wallet = self.node_wallet,
275
+ )
276
+ if success and broadcast:
277
+ self.gossip.broadcast_transaction(
278
+ tx = tx,
279
+ peers = self.peer_manager.get_active_peers(),
280
+ )
281
+ return success, result
282
+
283
+ def get_balance(self, address: str) -> float:
284
+ return self.blockchain.get_balance(address)
285
+
286
+ def get_block(self, block_hash: str):
287
+ return self.blockchain.get_block(block_hash)
288
+
289
+ def get_chain_tip(self):
290
+ return self.blockchain.get_chain_tip()
291
+
292
+ def get_status(self) -> dict:
293
+ tip = self.blockchain.get_chain_tip()
294
+ return {
295
+ "node_wallet": self.node_wallet,
296
+ "node_url": self.node_url,
297
+ "platform": self.platform,
298
+ "block_height": tip.index if tip else 0,
299
+ "peer_count": self.peer_manager.peer_count(),
300
+ "consensus": self.consensus.get_status(),
301
+ "sync": self.sync.get_sync_status(),
302
+ "plugins": self.plugin_manager.get_status(),
303
+ }
@@ -0,0 +1,4 @@
1
+ from playweb.plugin.base_plugin import BasePlugin
2
+ from playweb.plugin.plugin_manager import PluginManager
3
+
4
+ __all__ = ["BasePlugin", "PluginManager"]
@@ -0,0 +1,225 @@
1
+ """
2
+ PlayWebit Network — Base Plugin Interface
3
+ L2 apps implement this to plug into a PlayWebit node.
4
+ CipherVault, MusicApp, ArtApp etc all implement BasePlugin.
5
+
6
+ The plugin gets:
7
+ - Event hooks from L1 (on_block_finalised, on_transaction etc)
8
+ - SDK interface to call L1 (submit_tx, get_balance etc)
9
+ - Clean separation — plugin cannot touch L1 internals directly
10
+ """
11
+
12
+ from abc import ABC, abstractmethod
13
+ from typing import Optional, Dict, TYPE_CHECKING
14
+
15
+ if TYPE_CHECKING:
16
+ from playweb.core.block import Block
17
+ from playweb.core.transaction import Transaction
18
+
19
+
20
+ class BasePlugin(ABC):
21
+
22
+ # ─────────────────────────────────────────────────────────────
23
+ # Plugin identity — subclass must set these
24
+ # ─────────────────────────────────────────────────────────────
25
+
26
+ plugin_id: str = "base" # e.g. "ciphervault"
27
+ plugin_name: str = "Base Plugin" # e.g. "CipherVault"
28
+ plugin_version: str = "1.0.0"
29
+ platform_wallet: str = "" # plugin's PLWB treasury wallet
30
+
31
+ def __init__(self):
32
+ self._node = None # set by plugin_manager on register
33
+
34
+ def _set_node(self, node):
35
+ """Called by PluginManager when plugin is registered."""
36
+ self._node = node
37
+
38
+ # ─────────────────────────────────────────────────────────────
39
+ # Lifecycle hooks — L1 calls these
40
+ # ─────────────────────────────────────────────────────────────
41
+
42
+ def on_start(self, node):
43
+ """
44
+ Called when the node starts up.
45
+ Use this to initialise your plugin's own state.
46
+ """
47
+ pass
48
+
49
+ def on_stop(self):
50
+ """Called when the node shuts down gracefully."""
51
+ pass
52
+
53
+ def on_block_finalised(self, block: "Block"):
54
+ """
55
+ Called after every block is finalised by consensus.
56
+ NVF: Cyclic re-entry notification.
57
+ Use this to update your L2 state from confirmed transactions.
58
+ """
59
+ pass
60
+
61
+ def on_transaction(self, tx: "Transaction"):
62
+ """
63
+ Called when a new transaction is confirmed on chain.
64
+ Note: also fires via on_block_finalised for each tx in block.
65
+ """
66
+ pass
67
+
68
+ def on_content_registered(self, cid: str, owner: str, platform: str):
69
+ """Called when new content is registered on the network."""
70
+ pass
71
+
72
+ def on_edition_transferred(
73
+ self,
74
+ cid: str,
75
+ edition_number: int,
76
+ from_wallet: str,
77
+ to_wallet: str,
78
+ ):
79
+ """Called when an edition changes hands."""
80
+ pass
81
+
82
+ # ─────────────────────────────────────────────────────────────
83
+ # SDK interface — plugin calls L1 via these
84
+ # These are convenience wrappers around node methods
85
+ # ─────────────────────────────────────────────────────────────
86
+
87
+ def submit_transaction(self, tx: "Transaction") -> tuple:
88
+ """Submit a transaction to L1. Returns (success, tx_hash_or_reason)."""
89
+ if not self._node:
90
+ return False, "Plugin not attached to node"
91
+ return self._node.blockchain.add_transaction(
92
+ tx = tx,
93
+ node_wallet = self._node.node_wallet,
94
+ )
95
+
96
+ def get_balance(self, address: str) -> float:
97
+ """Get PLWB balance for any address."""
98
+ if not self._node:
99
+ return 0.0
100
+ return self._node.blockchain.get_balance(address)
101
+
102
+ def register_content(
103
+ self,
104
+ cid: str,
105
+ owner_wallet: str,
106
+ editions: int = 1,
107
+ royalty_pct: float = 0,
108
+ signature: str = None,
109
+ extra_data: Dict = None,
110
+ ) -> tuple:
111
+ """Register a CID on the network. Returns (success, reason, tx_hash)."""
112
+ if not self._node:
113
+ return False, "Plugin not attached to node", None
114
+ return self._node.content_registry.register(
115
+ cid = cid,
116
+ owner_wallet = owner_wallet,
117
+ platform_id = self.plugin_id,
118
+ editions = editions,
119
+ royalty_pct = royalty_pct,
120
+ signature = signature,
121
+ extra_data = extra_data,
122
+ )
123
+
124
+ def transfer_ownership(
125
+ self,
126
+ cid: str,
127
+ from_wallet: str,
128
+ to_wallet: str,
129
+ signature: str = None,
130
+ sale_price: float = 0,
131
+ ) -> tuple:
132
+ """Transfer ownership of a CID. Returns (success, reason, tx_hash)."""
133
+ if not self._node:
134
+ return False, "Plugin not attached to node", None
135
+ return self._node.content_registry.transfer_ownership(
136
+ cid = cid,
137
+ from_wallet = from_wallet,
138
+ to_wallet = to_wallet,
139
+ signature = signature,
140
+ platform_id = self.plugin_id,
141
+ sale_price = sale_price,
142
+ )
143
+
144
+ def verify_ownership(self, cid: str, wallet: str) -> bool:
145
+ """Verify wallet owns a CID."""
146
+ if not self._node:
147
+ return False
148
+ return self._node.content_registry.verify_ownership(cid, wallet)
149
+
150
+ def check_duplicate(self, cid: str) -> Dict:
151
+ """Check if CID is already registered anywhere on the network."""
152
+ if not self._node:
153
+ return {"exists": False}
154
+ return self._node.content_registry.check_duplicate(cid)
155
+
156
+ def anchor_spider_hash(
157
+ self,
158
+ chain_name: str,
159
+ spider_hash: str,
160
+ event_type: str = "integrity_check",
161
+ metadata: Dict = None,
162
+ signature: str = None,
163
+ ) -> tuple:
164
+ """
165
+ Anchor a SpiderWeave hash on the chain.
166
+ This is what spiderweave-sdk calls internally.
167
+ Returns (success, tx_hash).
168
+ """
169
+ if not self._node:
170
+ return False, "Plugin not attached to node"
171
+
172
+ from playweb.core.transaction import Transaction
173
+ tx = Transaction(
174
+ from_addr = self.platform_wallet.lower(),
175
+ to_addr = self.platform_wallet.lower(),
176
+ amount = 0,
177
+ tx_type = "spider_hash_anchor",
178
+ signature = signature,
179
+ spider_hash = spider_hash,
180
+ chain_name = chain_name,
181
+ data = {
182
+ "event_type": event_type,
183
+ "platform_id": self.plugin_id,
184
+ **(metadata or {}),
185
+ },
186
+ )
187
+ success, result = self.submit_transaction(tx)
188
+ return success, result
189
+
190
+ def get_editions(self, cid: str) -> list:
191
+ """Get all editions for a CID."""
192
+ if not self._node:
193
+ return []
194
+ return self._node.edition_registry.get_all_editions(cid)
195
+
196
+ def get_nft_metadata(self, cid: str, chain_record: dict) -> dict:
197
+ """
198
+ Optional: enrich NFT metadata for MetaMask display.
199
+ Called by /api/metadata/{cid} endpoint.
200
+
201
+ Return dict to merge with default metadata:
202
+ {
203
+ "name": "My File Name",
204
+ "image": "data:image/jpeg;base64,...", or IPFS URL
205
+ "attributes": [...additional attributes...]
206
+ }
207
+
208
+ Return None to use default chain metadata only.
209
+ """
210
+ return None
211
+
212
+ # ─────────────────────────────────────────────────────────────
213
+ # Plugin info
214
+ # ─────────────────────────────────────────────────────────────
215
+
216
+ def get_info(self) -> Dict:
217
+ return {
218
+ "plugin_id": self.plugin_id,
219
+ "plugin_name": self.plugin_name,
220
+ "plugin_version": self.plugin_version,
221
+ "platform_wallet": self.platform_wallet,
222
+ }
223
+
224
+ def __repr__(self):
225
+ return f"Plugin({self.plugin_id} v{self.plugin_version})"
@@ -0,0 +1,131 @@
1
+ """
2
+ PlayWebit Network — Plugin Manager
3
+ Loads and manages L2 plugins.
4
+ Notifies all registered plugins when L1 events happen.
5
+ """
6
+
7
+ import logging
8
+ from typing import Dict, List, Optional, TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ from playweb.plugin.base_plugin import BasePlugin
12
+ from playweb.core.block import Block
13
+ from playweb.core.transaction import Transaction
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class PluginManager:
19
+
20
+ def __init__(self):
21
+ self._plugins: Dict[str, "BasePlugin"] = {}
22
+ self._node = None
23
+
24
+ def set_node(self, node):
25
+ self._node = node
26
+
27
+ # ─────────────────────────────────────────────────────────────
28
+ # Register plugins
29
+ # ─────────────────────────────────────────────────────────────
30
+
31
+ def register(self, plugin: "BasePlugin"):
32
+ """Register a L2 plugin with this node."""
33
+ if plugin.plugin_id in self._plugins:
34
+ logger.warning(
35
+ f"Plugin {plugin.plugin_id} already registered — replacing"
36
+ )
37
+
38
+ plugin._set_node(self._node)
39
+ self._plugins[plugin.plugin_id] = plugin
40
+ logger.info(
41
+ f"Plugin registered: {plugin.plugin_name} "
42
+ f"(v{plugin.plugin_version})"
43
+ )
44
+
45
+ def get_plugin(self, plugin_id: str) -> Optional["BasePlugin"]:
46
+ return self._plugins.get(plugin_id)
47
+
48
+ def get_all_plugins(self) -> List["BasePlugin"]:
49
+ return list(self._plugins.values())
50
+
51
+ # ─────────────────────────────────────────────────────────────
52
+ # Lifecycle notifications
53
+ # ─────────────────────────────────────────────────────────────
54
+
55
+ def notify_start(self, node):
56
+ """Notify all plugins that the node has started."""
57
+ for plugin in self._plugins.values():
58
+ try:
59
+ plugin.on_start(node)
60
+ except Exception as e:
61
+ logger.error(
62
+ f"Plugin {plugin.plugin_id} on_start error: {e}",
63
+ exc_info=True
64
+ )
65
+
66
+ def notify_stop(self):
67
+ """Notify all plugins that the node is stopping."""
68
+ for plugin in self._plugins.values():
69
+ try:
70
+ plugin.on_stop()
71
+ except Exception as e:
72
+ logger.error(f"Plugin {plugin.plugin_id} on_stop error: {e}")
73
+
74
+ # ─────────────────────────────────────────────────────────────
75
+ # Block / Transaction notifications
76
+ # NVF: Cyclic re-entry
77
+ # ─────────────────────────────────────────────────────────────
78
+
79
+ def notify_block_finalised(self, block: "Block"):
80
+ """
81
+ Notify all plugins that a block has been finalised.
82
+ NVF paper: Cyclic re-entry notification after ANCHOR phase.
83
+ Each plugin processes transactions relevant to their platform.
84
+ """
85
+ for plugin in self._plugins.values():
86
+ try:
87
+ plugin.on_block_finalised(block)
88
+
89
+ # Also notify per-transaction
90
+ for tx in block.transactions:
91
+ plugin.on_transaction(tx)
92
+
93
+ # Special hooks for content events
94
+ if tx.tx_type == "content_register" and tx.cid:
95
+ plugin.on_content_registered(
96
+ cid = tx.cid,
97
+ owner = tx.to_addr,
98
+ platform = tx.data.get("platform_id", "unknown")
99
+ if tx.data else "unknown",
100
+ )
101
+
102
+ elif tx.tx_type == "edition_transfer" and tx.cid:
103
+ plugin.on_edition_transferred(
104
+ cid = tx.cid,
105
+ edition_number = tx.edition_number or 0,
106
+ from_wallet = tx.from_addr,
107
+ to_wallet = tx.to_addr,
108
+ )
109
+
110
+ except Exception as e:
111
+ logger.error(
112
+ f"Plugin {plugin.plugin_id} block notification error: {e}",
113
+ exc_info=True,
114
+ )
115
+ # Never let a plugin crash the node
116
+
117
+ def notify_transaction(self, tx: "Transaction"):
118
+ """Notify all plugins of a new transaction (before block)."""
119
+ for plugin in self._plugins.values():
120
+ try:
121
+ plugin.on_transaction(tx)
122
+ except Exception as e:
123
+ logger.error(
124
+ f"Plugin {plugin.plugin_id} tx notification error: {e}"
125
+ )
126
+
127
+ def get_status(self) -> Dict:
128
+ return {
129
+ "plugin_count": len(self._plugins),
130
+ "plugins": [p.get_info() for p in self._plugins.values()],
131
+ }
@@ -0,0 +1,4 @@
1
+ from playweb.registry.content_registry import ContentRegistry
2
+ from playweb.registry.edition_registry import EditionRegistry
3
+
4
+ __all__ = ["ContentRegistry", "EditionRegistry"]