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
playweb/core/block.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PlayWebit Network — Block
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
import logging
|
|
9
|
+
from typing import List, Dict, Optional
|
|
10
|
+
|
|
11
|
+
from playweb.core.transaction import Transaction
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Block:
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
index: int,
|
|
21
|
+
transactions: List[Transaction],
|
|
22
|
+
previous_hash: str,
|
|
23
|
+
validator_wallet: str,
|
|
24
|
+
timestamp: float = None,
|
|
25
|
+
nonce: int = 0,
|
|
26
|
+
consensus_round: int = 0,
|
|
27
|
+
votes: List = None,
|
|
28
|
+
):
|
|
29
|
+
self.index = index
|
|
30
|
+
self.transactions = transactions
|
|
31
|
+
self.previous_hash = previous_hash
|
|
32
|
+
self.validator_wallet = validator_wallet.lower()
|
|
33
|
+
self.timestamp = timestamp or time.time()
|
|
34
|
+
self.nonce = nonce
|
|
35
|
+
self.consensus_round = consensus_round
|
|
36
|
+
self.votes = votes or []
|
|
37
|
+
|
|
38
|
+
self.merkle_root = self.calculate_merkle_root()
|
|
39
|
+
self.hash = self.calculate_hash()
|
|
40
|
+
|
|
41
|
+
# ─────────────────────────────────────────────────────────────
|
|
42
|
+
# Merkle Tree
|
|
43
|
+
# ─────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
def calculate_merkle_root(self) -> str:
|
|
46
|
+
"""Build merkle root from transaction hashes."""
|
|
47
|
+
if not self.transactions:
|
|
48
|
+
return hashlib.sha256(b"empty").hexdigest()
|
|
49
|
+
|
|
50
|
+
hashes = [tx.hash for tx in self.transactions]
|
|
51
|
+
|
|
52
|
+
while len(hashes) > 1:
|
|
53
|
+
# Pad odd number of hashes
|
|
54
|
+
if len(hashes) % 2 != 0:
|
|
55
|
+
hashes.append(hashes[-1])
|
|
56
|
+
|
|
57
|
+
hashes = [
|
|
58
|
+
hashlib.sha256(
|
|
59
|
+
(hashes[i] + hashes[i + 1]).encode()
|
|
60
|
+
).hexdigest()
|
|
61
|
+
for i in range(0, len(hashes), 2)
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
return hashes[0]
|
|
65
|
+
|
|
66
|
+
# ─────────────────────────────────────────────────────────────
|
|
67
|
+
# Hashing
|
|
68
|
+
# ─────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
def calculate_hash(self) -> str:
|
|
71
|
+
block_data = {
|
|
72
|
+
"index": self.index,
|
|
73
|
+
"previous_hash": self.previous_hash,
|
|
74
|
+
"merkle_root": self.merkle_root,
|
|
75
|
+
"timestamp": self.timestamp,
|
|
76
|
+
"nonce": self.nonce,
|
|
77
|
+
"validator_wallet": self.validator_wallet,
|
|
78
|
+
"consensus_round": self.consensus_round,
|
|
79
|
+
}
|
|
80
|
+
raw = json.dumps(block_data, sort_keys=True)
|
|
81
|
+
return hashlib.sha256(raw.encode()).hexdigest()
|
|
82
|
+
|
|
83
|
+
# ─────────────────────────────────────────────────────────────
|
|
84
|
+
# Validation
|
|
85
|
+
# ─────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
def validate(self, previous_block: Optional["Block"] = None) -> tuple[bool, str]:
|
|
88
|
+
"""
|
|
89
|
+
Validate block integrity.
|
|
90
|
+
Returns (is_valid, reason).
|
|
91
|
+
"""
|
|
92
|
+
# Hash integrity
|
|
93
|
+
if self.hash != self.calculate_hash():
|
|
94
|
+
return False, "Block hash mismatch"
|
|
95
|
+
|
|
96
|
+
# Merkle root integrity
|
|
97
|
+
if self.merkle_root != self.calculate_merkle_root():
|
|
98
|
+
return False, "Merkle root mismatch"
|
|
99
|
+
|
|
100
|
+
# Chain linkage
|
|
101
|
+
if previous_block:
|
|
102
|
+
if self.previous_hash != previous_block.hash:
|
|
103
|
+
return False, "Previous hash does not match"
|
|
104
|
+
if self.index != previous_block.index + 1:
|
|
105
|
+
return False, f"Block index gap: {previous_block.index} → {self.index}"
|
|
106
|
+
|
|
107
|
+
# Validate each transaction
|
|
108
|
+
for tx in self.transactions:
|
|
109
|
+
valid, reason = tx.validate_fields()
|
|
110
|
+
if not valid:
|
|
111
|
+
return False, f"Invalid tx {tx.hash[:12]}: {reason}"
|
|
112
|
+
|
|
113
|
+
return True, "Valid"
|
|
114
|
+
|
|
115
|
+
# ─────────────────────────────────────────────────────────────
|
|
116
|
+
# Serialisation
|
|
117
|
+
# ─────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
def to_dict(self) -> Dict:
|
|
120
|
+
return {
|
|
121
|
+
"index": self.index,
|
|
122
|
+
"hash": self.hash,
|
|
123
|
+
"previous_hash": self.previous_hash,
|
|
124
|
+
"merkle_root": self.merkle_root,
|
|
125
|
+
"timestamp": self.timestamp,
|
|
126
|
+
"nonce": self.nonce,
|
|
127
|
+
"validator_wallet": self.validator_wallet,
|
|
128
|
+
"consensus_round": self.consensus_round,
|
|
129
|
+
"votes": self.votes,
|
|
130
|
+
"transactions": [tx.to_dict() for tx in self.transactions],
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
@classmethod
|
|
134
|
+
def from_dict(cls, d: Dict) -> "Block":
|
|
135
|
+
txs = [Transaction.from_dict(t) for t in d.get("transactions", [])]
|
|
136
|
+
block = cls(
|
|
137
|
+
index = d["index"],
|
|
138
|
+
transactions = txs,
|
|
139
|
+
previous_hash = d["previous_hash"],
|
|
140
|
+
validator_wallet = d["validator_wallet"],
|
|
141
|
+
timestamp = d["timestamp"],
|
|
142
|
+
nonce = d.get("nonce", 0),
|
|
143
|
+
consensus_round = d.get("consensus_round", 0),
|
|
144
|
+
votes = d.get("votes", []),
|
|
145
|
+
)
|
|
146
|
+
# Restore stored hash
|
|
147
|
+
block.hash = d["hash"]
|
|
148
|
+
block.merkle_root = d["merkle_root"]
|
|
149
|
+
return block
|
|
150
|
+
|
|
151
|
+
def __repr__(self):
|
|
152
|
+
return (
|
|
153
|
+
f"Block(index={self.index}, "
|
|
154
|
+
f"txs={len(self.transactions)}, "
|
|
155
|
+
f"hash={self.hash[:12]}...)"
|
|
156
|
+
)
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PlayWebit Network — Blockchain
|
|
3
|
+
Storage-agnostic. No Supabase imports. No CipherVault logic.
|
|
4
|
+
Works with any ChainStorage implementation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import time
|
|
8
|
+
import logging
|
|
9
|
+
from typing import List, Optional, Tuple, Dict
|
|
10
|
+
|
|
11
|
+
from playweb.core.block import Block
|
|
12
|
+
from playweb.core.transaction import Transaction
|
|
13
|
+
from playweb.core.mempool import Mempool
|
|
14
|
+
from playweb.core.fee_engine import FeeEngine
|
|
15
|
+
from playweb.config import (
|
|
16
|
+
CHAIN_ID,
|
|
17
|
+
AUTHORITY_WALLET,
|
|
18
|
+
AUTHORITY_TX_TYPES,
|
|
19
|
+
L1_TX_TYPES,
|
|
20
|
+
MAX_TX_PER_BLOCK,
|
|
21
|
+
TRANSACTION_FEE,
|
|
22
|
+
CV_LINK_FEE,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Blockchain:
|
|
29
|
+
|
|
30
|
+
def __init__(self, storage):
|
|
31
|
+
"""
|
|
32
|
+
storage — any ChainStorage implementation.
|
|
33
|
+
LevelDBStorage, SQLiteStorage, SupabaseStorage, RAMStorage.
|
|
34
|
+
"""
|
|
35
|
+
self.storage = storage
|
|
36
|
+
self.mempool = Mempool()
|
|
37
|
+
self.fee_engine = FeeEngine()
|
|
38
|
+
self._ensure_genesis()
|
|
39
|
+
|
|
40
|
+
# ─────────────────────────────────────────────────────────────
|
|
41
|
+
# Genesis
|
|
42
|
+
# ─────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
def _ensure_genesis(self):
|
|
45
|
+
"""Create genesis block if chain is empty."""
|
|
46
|
+
if self.storage.get_chain_length() == 0:
|
|
47
|
+
logger.info("Chain is empty — creating genesis block")
|
|
48
|
+
genesis_tx = Transaction(
|
|
49
|
+
from_addr = AUTHORITY_WALLET,
|
|
50
|
+
to_addr = AUTHORITY_WALLET,
|
|
51
|
+
amount = 0,
|
|
52
|
+
tx_type = "genesis",
|
|
53
|
+
nonce = 0,
|
|
54
|
+
timestamp = 0,
|
|
55
|
+
data = {
|
|
56
|
+
"chain_id": CHAIN_ID,
|
|
57
|
+
"message": "PlayWebit Network Genesis",
|
|
58
|
+
"created_at": time.time(),
|
|
59
|
+
}
|
|
60
|
+
)
|
|
61
|
+
genesis_block = Block(
|
|
62
|
+
index = 0,
|
|
63
|
+
transactions = [genesis_tx],
|
|
64
|
+
previous_hash = "0" * 64,
|
|
65
|
+
validator_wallet = AUTHORITY_WALLET,
|
|
66
|
+
timestamp = 0,
|
|
67
|
+
)
|
|
68
|
+
self.storage.save_block(genesis_block)
|
|
69
|
+
logger.info(f"Genesis block created: {genesis_block.hash[:16]}...")
|
|
70
|
+
|
|
71
|
+
# ─────────────────────────────────────────────────────────────
|
|
72
|
+
# Transactions
|
|
73
|
+
# ─────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
def add_transaction(
|
|
76
|
+
self,
|
|
77
|
+
tx: Transaction,
|
|
78
|
+
node_wallet: str = None,
|
|
79
|
+
) -> Tuple[bool, str]:
|
|
80
|
+
"""
|
|
81
|
+
Validate and add a transaction to the mempool.
|
|
82
|
+
Also creates and queues the required fee transactions.
|
|
83
|
+
Returns (success, reason).
|
|
84
|
+
"""
|
|
85
|
+
# Field validation
|
|
86
|
+
valid, reason = tx.validate_fields()
|
|
87
|
+
if not valid:
|
|
88
|
+
return False, reason
|
|
89
|
+
|
|
90
|
+
# Signature verification (skipped for authority txs)
|
|
91
|
+
if tx.tx_type not in AUTHORITY_TX_TYPES:
|
|
92
|
+
if not tx.verify_signature():
|
|
93
|
+
return False, "Invalid signature"
|
|
94
|
+
|
|
95
|
+
# Balance check (skip for authority txs)
|
|
96
|
+
if tx.tx_type not in AUTHORITY_TX_TYPES and tx.amount > 0:
|
|
97
|
+
fee_info = self.fee_engine.calculate_fee(tx.tx_type, tx.amount)
|
|
98
|
+
total_needed = tx.amount + fee_info["total"]
|
|
99
|
+
balance = self.get_balance(tx.from_addr)
|
|
100
|
+
|
|
101
|
+
if balance < total_needed:
|
|
102
|
+
return (
|
|
103
|
+
False,
|
|
104
|
+
f"Insufficient balance: need {total_needed} PLWB, "
|
|
105
|
+
f"have {balance} PLWB"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Duplicate check
|
|
109
|
+
if self.mempool.contains(tx.hash):
|
|
110
|
+
return False, "Transaction already in mempool"
|
|
111
|
+
|
|
112
|
+
if self.storage.get_transaction(tx.hash):
|
|
113
|
+
return False, "Transaction already on chain"
|
|
114
|
+
|
|
115
|
+
# Add to mempool
|
|
116
|
+
success, reason = self.mempool.add(tx)
|
|
117
|
+
if not success:
|
|
118
|
+
return False, reason
|
|
119
|
+
|
|
120
|
+
# Create and queue fee transactions
|
|
121
|
+
if node_wallet and tx.tx_type not in AUTHORITY_TX_TYPES:
|
|
122
|
+
fee_txs = self.fee_engine.create_fee_transactions(tx, node_wallet)
|
|
123
|
+
for fee_tx in fee_txs:
|
|
124
|
+
self.mempool.add(fee_tx)
|
|
125
|
+
|
|
126
|
+
logger.info(f"Transaction added to mempool: {tx.hash[:16]} ({tx.tx_type})")
|
|
127
|
+
return True, tx.hash
|
|
128
|
+
|
|
129
|
+
def get_transaction(self, tx_hash: str) -> Optional[Transaction]:
|
|
130
|
+
"""Get a transaction by hash — checks mempool first, then chain."""
|
|
131
|
+
# Check mempool first
|
|
132
|
+
tx = self.mempool.get(tx_hash)
|
|
133
|
+
if tx:
|
|
134
|
+
return tx
|
|
135
|
+
# Then check storage
|
|
136
|
+
return self.storage.get_transaction(tx_hash)
|
|
137
|
+
|
|
138
|
+
def validate_transaction(
|
|
139
|
+
self,
|
|
140
|
+
tx: Transaction,
|
|
141
|
+
node_wallet: str = None,
|
|
142
|
+
) -> Tuple[bool, str]:
|
|
143
|
+
"""
|
|
144
|
+
Full transaction validation.
|
|
145
|
+
Used during consensus when receiving blocks from peers.
|
|
146
|
+
"""
|
|
147
|
+
valid, reason = tx.validate_fields()
|
|
148
|
+
if not valid:
|
|
149
|
+
return False, reason
|
|
150
|
+
|
|
151
|
+
if tx.tx_type not in AUTHORITY_TX_TYPES:
|
|
152
|
+
if not tx.verify_signature():
|
|
153
|
+
return False, "Invalid signature"
|
|
154
|
+
|
|
155
|
+
return True, "Valid"
|
|
156
|
+
|
|
157
|
+
# ─────────────────────────────────────────────────────────────
|
|
158
|
+
# Blocks
|
|
159
|
+
# ─────────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
def create_block(self, validator_wallet: str) -> Optional[Block]:
|
|
162
|
+
"""
|
|
163
|
+
Create a new block from pending mempool transactions.
|
|
164
|
+
Called by the consensus leader before proposing.
|
|
165
|
+
"""
|
|
166
|
+
pending = self.mempool.get_pending(MAX_TX_PER_BLOCK)
|
|
167
|
+
if not pending:
|
|
168
|
+
logger.debug("No pending transactions — skipping block creation")
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
tip = self.storage.get_chain_tip()
|
|
172
|
+
if not tip:
|
|
173
|
+
logger.error("No chain tip found — chain may be corrupt")
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
block = Block(
|
|
177
|
+
index = tip.index + 1,
|
|
178
|
+
transactions = pending,
|
|
179
|
+
previous_hash = tip.hash,
|
|
180
|
+
validator_wallet = validator_wallet,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
logger.info(
|
|
184
|
+
f"Block created: index={block.index}, "
|
|
185
|
+
f"txs={len(pending)}, "
|
|
186
|
+
f"hash={block.hash[:16]}..."
|
|
187
|
+
)
|
|
188
|
+
return block
|
|
189
|
+
|
|
190
|
+
def add_block(
|
|
191
|
+
self,
|
|
192
|
+
block: Block,
|
|
193
|
+
votes: List = None,
|
|
194
|
+
node_wallet: str = None,
|
|
195
|
+
) -> Tuple[bool, str]:
|
|
196
|
+
"""
|
|
197
|
+
Add a finalised block to the chain.
|
|
198
|
+
Called after consensus (2/3 votes received).
|
|
199
|
+
Validates block then writes to storage.
|
|
200
|
+
"""
|
|
201
|
+
# Get previous block for chain validation
|
|
202
|
+
tip = self.storage.get_chain_tip()
|
|
203
|
+
|
|
204
|
+
# Validate block integrity
|
|
205
|
+
valid, reason = block.validate(previous_block=tip)
|
|
206
|
+
if not valid:
|
|
207
|
+
return False, f"Block validation failed: {reason}"
|
|
208
|
+
|
|
209
|
+
# Validate fee transactions (50/50 split enforced)
|
|
210
|
+
if node_wallet:
|
|
211
|
+
fee_valid, fee_reason = self.fee_engine.validate_fee_transactions(
|
|
212
|
+
block, node_wallet
|
|
213
|
+
)
|
|
214
|
+
if not fee_valid:
|
|
215
|
+
return False, f"Fee validation failed: {fee_reason}"
|
|
216
|
+
|
|
217
|
+
# Attach votes
|
|
218
|
+
if votes:
|
|
219
|
+
block.votes = votes
|
|
220
|
+
|
|
221
|
+
# Save to storage
|
|
222
|
+
self.storage.save_block(block)
|
|
223
|
+
|
|
224
|
+
# Update balances
|
|
225
|
+
self._apply_block_to_balances(block)
|
|
226
|
+
|
|
227
|
+
# Update content registry for content_register transactions
|
|
228
|
+
self._apply_content_registry(block)
|
|
229
|
+
|
|
230
|
+
# Remove confirmed transactions from mempool
|
|
231
|
+
self.mempool.remove_batch([tx.hash for tx in block.transactions])
|
|
232
|
+
|
|
233
|
+
logger.info(
|
|
234
|
+
f"Block finalised: index={block.index}, "
|
|
235
|
+
f"txs={len(block.transactions)}, "
|
|
236
|
+
f"hash={block.hash[:16]}..."
|
|
237
|
+
)
|
|
238
|
+
return True, block.hash
|
|
239
|
+
|
|
240
|
+
def _apply_block_to_balances(self, block: Block):
|
|
241
|
+
"""Update balance records for all transactions in a block."""
|
|
242
|
+
for tx in block.transactions:
|
|
243
|
+
if tx.amount <= 0:
|
|
244
|
+
continue
|
|
245
|
+
|
|
246
|
+
# Deduct from sender (not for authority/genesis txs)
|
|
247
|
+
if tx.tx_type not in AUTHORITY_TX_TYPES:
|
|
248
|
+
from_bal = self.storage.get_balance(tx.from_addr)
|
|
249
|
+
self.storage.save_balance(tx.from_addr, from_bal - tx.amount)
|
|
250
|
+
|
|
251
|
+
# Credit receiver
|
|
252
|
+
to_bal = self.storage.get_balance(tx.to_addr)
|
|
253
|
+
self.storage.save_balance(tx.to_addr, to_bal + tx.amount)
|
|
254
|
+
|
|
255
|
+
# Save transaction record
|
|
256
|
+
self.storage.save_transaction(tx)
|
|
257
|
+
|
|
258
|
+
def _apply_content_registry(self, block: Block):
|
|
259
|
+
"""Register CIDs and editions from content_register transactions."""
|
|
260
|
+
for tx in block.transactions:
|
|
261
|
+
if tx.tx_type == "content_register" and tx.cid:
|
|
262
|
+
record = {
|
|
263
|
+
"cid": tx.cid,
|
|
264
|
+
"creator_wallet": tx.from_addr,
|
|
265
|
+
"first_owner": tx.to_addr,
|
|
266
|
+
"first_platform": tx.data.get("platform_id", "unknown"),
|
|
267
|
+
"first_tx_hash": tx.hash,
|
|
268
|
+
"first_block": block.index,
|
|
269
|
+
"timestamp": tx.timestamp,
|
|
270
|
+
"total_editions": tx.editions or 1,
|
|
271
|
+
"royalty_pct": tx.royalty_pct or 0,
|
|
272
|
+
}
|
|
273
|
+
self.storage.save_content_record(record)
|
|
274
|
+
|
|
275
|
+
elif tx.tx_type == "ownership_transfer" and tx.cid:
|
|
276
|
+
# Update current owner
|
|
277
|
+
existing = self.storage.get_content_record(tx.cid)
|
|
278
|
+
if existing:
|
|
279
|
+
existing["current_owner"] = tx.to_addr
|
|
280
|
+
self.storage.save_content_record(existing)
|
|
281
|
+
|
|
282
|
+
elif tx.tx_type == "edition_transfer" and tx.cid:
|
|
283
|
+
edition_record = {
|
|
284
|
+
"cid": tx.cid,
|
|
285
|
+
"edition_number": tx.edition_number,
|
|
286
|
+
"current_owner": tx.to_addr,
|
|
287
|
+
"platform": tx.data.get("platform_id", "unknown"),
|
|
288
|
+
"tx_hash": tx.hash,
|
|
289
|
+
"timestamp": tx.timestamp,
|
|
290
|
+
}
|
|
291
|
+
self.storage.save_edition_record(edition_record)
|
|
292
|
+
|
|
293
|
+
# ─────────────────────────────────────────────────────────────
|
|
294
|
+
# Balances
|
|
295
|
+
# ─────────────────────────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
def get_balance(self, address: str) -> float:
|
|
298
|
+
"""Get PLWB balance for an address."""
|
|
299
|
+
return self.storage.get_balance(address.lower())
|
|
300
|
+
|
|
301
|
+
# ─────────────────────────────────────────────────────────────
|
|
302
|
+
# Chain queries
|
|
303
|
+
# ─────────────────────────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
def get_block(self, block_hash: str) -> Optional[Block]:
|
|
306
|
+
return self.storage.get_block(block_hash)
|
|
307
|
+
|
|
308
|
+
def get_block_by_index(self, index: int) -> Optional[Block]:
|
|
309
|
+
return self.storage.get_block_by_index(index)
|
|
310
|
+
|
|
311
|
+
def get_chain_tip(self) -> Optional[Block]:
|
|
312
|
+
return self.storage.get_chain_tip()
|
|
313
|
+
|
|
314
|
+
def get_chain_length(self) -> int:
|
|
315
|
+
return self.storage.get_chain_length()
|
|
316
|
+
|
|
317
|
+
def get_pending_transactions(self) -> List[Transaction]:
|
|
318
|
+
return self.mempool.get_pending()
|
|
319
|
+
|
|
320
|
+
def get_blocks_from(self, from_index: int, limit: int = 50) -> List[Block]:
|
|
321
|
+
"""Get a range of blocks — used for chain sync."""
|
|
322
|
+
return self.storage.get_blocks_from(from_index, limit)
|
|
323
|
+
|
|
324
|
+
# ─────────────────────────────────────────────────────────────
|
|
325
|
+
# Chain validation
|
|
326
|
+
# ─────────────────────────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
def validate_chain(self) -> Tuple[bool, str]:
|
|
329
|
+
"""
|
|
330
|
+
Validate entire chain integrity.
|
|
331
|
+
Used during sync to verify downloaded chain from peers.
|
|
332
|
+
"""
|
|
333
|
+
length = self.storage.get_chain_length()
|
|
334
|
+
if length == 0:
|
|
335
|
+
return False, "Empty chain"
|
|
336
|
+
|
|
337
|
+
previous_block = None
|
|
338
|
+
for i in range(length):
|
|
339
|
+
block = self.storage.get_block_by_index(i)
|
|
340
|
+
if not block:
|
|
341
|
+
return False, f"Missing block at index {i}"
|
|
342
|
+
|
|
343
|
+
valid, reason = block.validate(previous_block)
|
|
344
|
+
if not valid:
|
|
345
|
+
return False, f"Block {i} invalid: {reason}"
|
|
346
|
+
|
|
347
|
+
previous_block = block
|
|
348
|
+
|
|
349
|
+
return True, f"Chain valid ({length} blocks)"
|
|
350
|
+
|
|
351
|
+
def get_stats(self) -> Dict:
|
|
352
|
+
tip = self.get_chain_tip()
|
|
353
|
+
return {
|
|
354
|
+
"chain_length": self.get_chain_length(),
|
|
355
|
+
"chain_tip": tip.hash if tip else None,
|
|
356
|
+
"pending_tx_count": self.mempool.size(),
|
|
357
|
+
"chain_id": CHAIN_ID,
|
|
358
|
+
}
|