paul-purecipher commited on
Commit
d4e49d9
·
unverified ·
2 Parent(s): 7640628dc0782c

Merge pull request #2 from PureCipher/feature/provenance-ledger

Browse files
examples/ledger_example.py ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Example demonstrating the FastMCP Provenance Ledger functionality."""
2
+
3
+ import asyncio
4
+ from datetime import datetime
5
+ from fastmcp import FastMCP
6
+ from fastmcp.ledger import ProvenanceLedger, LedgerEvent, EventType
7
+
8
+
9
+ def create_ledger_server():
10
+ """Create a FastMCP server with ledger functionality."""
11
+ server = FastMCP("LedgerExampleServer")
12
+
13
+ # Enable the provenance ledger
14
+ ledger = server.enable_ledger(database_url="sqlite:///example_ledger.db")
15
+
16
+ @server.tool
17
+ def log_tool_call(tool_name: str, parameters: dict, result: str) -> str:
18
+ """Log a tool call to the provenance ledger."""
19
+ # Create a ledger event
20
+ event = LedgerEvent(
21
+ event_type=EventType.TOOL_CALL,
22
+ actor_id="system",
23
+ resource_id=f"tool://{tool_name}",
24
+ action="execute",
25
+ metadata={
26
+ "tool_name": tool_name,
27
+ "parameters": parameters,
28
+ "result": result,
29
+ "timestamp": datetime.utcnow().isoformat()
30
+ }
31
+ )
32
+
33
+ # Append to ledger
34
+ entry = ledger.append_event(event)
35
+
36
+ return f"Logged tool call {tool_name} as entry {entry.sequence_number}"
37
+
38
+ @server.tool
39
+ def log_policy_decision(policy_name: str, decision: str, context: dict) -> str:
40
+ """Log a policy decision to the provenance ledger."""
41
+ event = LedgerEvent(
42
+ event_type=EventType.POLICY_DECISION,
43
+ actor_id="policy_engine",
44
+ resource_id=f"policy://{policy_name}",
45
+ action="evaluate",
46
+ metadata={
47
+ "policy_name": policy_name,
48
+ "decision": decision,
49
+ "context": context,
50
+ "timestamp": datetime.utcnow().isoformat()
51
+ }
52
+ )
53
+
54
+ entry = ledger.append_event(event)
55
+ return f"Logged policy decision {policy_name} as entry {entry.sequence_number}"
56
+
57
+ @server.tool
58
+ def log_data_flow(source: str, destination: str, data_type: str, size: int) -> str:
59
+ """Log a data flow event to the provenance ledger."""
60
+ event = LedgerEvent(
61
+ event_type=EventType.DATA_FLOW,
62
+ actor_id="data_processor",
63
+ resource_id=f"data://{source}",
64
+ action="transfer",
65
+ metadata={
66
+ "source": source,
67
+ "destination": destination,
68
+ "data_type": data_type,
69
+ "size_bytes": size,
70
+ "timestamp": datetime.utcnow().isoformat()
71
+ }
72
+ )
73
+
74
+ entry = ledger.append_event(event)
75
+ return f"Logged data flow from {source} to {destination} as entry {entry.sequence_number}"
76
+
77
+ @server.tool
78
+ def verify_ledger_integrity() -> dict:
79
+ """Verify the integrity of the entire ledger."""
80
+ # Verify chain integrity
81
+ chain_valid = ledger.verify_chain_integrity()
82
+
83
+ # Get statistics
84
+ stats = ledger.get_ledger_statistics()
85
+
86
+ return {
87
+ "chain_integrity": chain_valid,
88
+ "statistics": stats,
89
+ "verification_timestamp": datetime.utcnow().isoformat()
90
+ }
91
+
92
+ @server.tool
93
+ def get_ledger_entry(sequence_number: int) -> dict:
94
+ """Get a specific ledger entry."""
95
+ entry = ledger.get_entry(sequence_number)
96
+
97
+ if not entry:
98
+ return {"error": f"Entry {sequence_number} not found"}
99
+
100
+ event = entry.get_event()
101
+
102
+ return {
103
+ "sequence_number": entry.sequence_number,
104
+ "entry_hash": entry.entry_hash,
105
+ "previous_hash": entry.previous_hash,
106
+ "created_at": entry.created_at.isoformat(),
107
+ "event": {
108
+ "type": event.event_type,
109
+ "actor_id": event.actor_id,
110
+ "action": event.action,
111
+ "metadata": event.metadata
112
+ }
113
+ }
114
+
115
+ return server, ledger
116
+
117
+
118
+ async def demonstrate_ledger_functionality():
119
+ """Demonstrate the ledger functionality."""
120
+ print("🚀 Creating FastMCP server with Provenance Ledger...")
121
+ server, ledger = create_ledger_server()
122
+
123
+ print("\n📝 Logging various events to the ledger...")
124
+
125
+ # Log some tool calls
126
+ print(server._tool_manager.call_tool("log_tool_call", {
127
+ "tool_name": "file_reader",
128
+ "parameters": {"path": "/data/file.txt"},
129
+ "result": "success"
130
+ }))
131
+
132
+ print(server._tool_manager.call_tool("log_tool_call", {
133
+ "tool_name": "data_processor",
134
+ "parameters": {"input": "raw_data", "format": "json"},
135
+ "result": "processed_data"
136
+ }))
137
+
138
+ # Log policy decisions
139
+ print(server._tool_manager.call_tool("log_policy_decision", {
140
+ "policy_name": "access_control",
141
+ "decision": "allow",
142
+ "context": {"user": "alice", "resource": "sensitive_data"}
143
+ }))
144
+
145
+ print(server._tool_manager.call_tool("log_policy_decision", {
146
+ "policy_name": "data_retention",
147
+ "decision": "delete",
148
+ "context": {"age_days": 365, "type": "logs"}
149
+ }))
150
+
151
+ # Log data flows
152
+ print(server._tool_manager.call_tool("log_data_flow", {
153
+ "source": "database",
154
+ "destination": "cache",
155
+ "data_type": "user_profiles",
156
+ "size": 1024
157
+ }))
158
+
159
+ print(server._tool_manager.call_tool("log_data_flow", {
160
+ "source": "api",
161
+ "destination": "analytics",
162
+ "data_type": "usage_metrics",
163
+ "size": 512
164
+ }))
165
+
166
+ print("\n🔍 Verifying ledger integrity...")
167
+ integrity_result = server._tool_manager.call_tool("verify_ledger_integrity", {})
168
+ print(f"Chain integrity: {integrity_result['chain_integrity']}")
169
+ print(f"Total entries: {integrity_result['statistics']['total_entries']}")
170
+ print(f"Total blocks: {integrity_result['statistics']['total_blocks']}")
171
+
172
+ print("\n📋 Retrieving specific entries...")
173
+ for i in range(1, 4):
174
+ entry = server._tool_manager.call_tool("get_ledger_entry", {"sequence_number": i})
175
+ if "error" not in entry:
176
+ print(f"Entry {i}: {entry['event']['type']} - {entry['event']['action']}")
177
+
178
+ print("\n🔗 Demonstrating hash chaining...")
179
+ entry1 = ledger.get_entry(1)
180
+ entry2 = ledger.get_entry(2)
181
+
182
+ if entry1 and entry2:
183
+ print(f"Entry 1 hash: {entry1.entry_hash[:16]}...")
184
+ print(f"Entry 2 previous hash: {entry2.previous_hash[:16]}...")
185
+ print(f"Hash chain intact: {entry2.previous_hash == entry1.entry_hash}")
186
+
187
+ print("\n🌳 Demonstrating Merkle tree verification...")
188
+ # Get the first block
189
+ block = ledger.get_block(1)
190
+ if block:
191
+ print(f"Block 1 Merkle root: {block.merkle_root[:16]}...")
192
+ print(f"Block 1 entry count: {block.entry_count}")
193
+
194
+ # Verify block integrity
195
+ block_valid = ledger.verify_block_integrity(1)
196
+ print(f"Block 1 integrity: {block_valid}")
197
+
198
+ print("\n✅ Ledger demonstration complete!")
199
+ print("\nThe ledger provides:")
200
+ print("- Tamper-evident hash chaining between entries")
201
+ print("- Merkle tree verification for blocks")
202
+ print("- Cryptographic integrity guarantees")
203
+ print("- Audit trail for all system events")
204
+ print("- HTTP API endpoints for external access")
205
+
206
+
207
+ if __name__ == "__main__":
208
+ asyncio.run(demonstrate_ledger_functionality())
src/fastmcp/ledger/__init__.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastMCP Ledger - Tamper-evident provenance ledger for auditability and non-repudiation."""
2
+
3
+ from .ledger import LedgerEntry, LedgerBlock, ProvenanceLedger, LedgerEvent, EventType
4
+ from .merkle import MerkleTree, MerkleProof
5
+ from .adapter import LedgerAdapter, HyperledgerAdapter
6
+
7
+ __all__ = [
8
+ "LedgerEntry",
9
+ "LedgerBlock",
10
+ "ProvenanceLedger",
11
+ "LedgerEvent",
12
+ "EventType",
13
+ "MerkleTree",
14
+ "MerkleProof",
15
+ "LedgerAdapter",
16
+ "HyperledgerAdapter"
17
+ ]
src/fastmcp/ledger/adapter.py ADDED
@@ -0,0 +1,384 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ledger adapters for external blockchain backends like Hyperledger/OmniSeal."""
2
+
3
+ import json
4
+ from abc import ABC, abstractmethod
5
+ from typing import Any, Dict, List, Optional
6
+ from datetime import datetime
7
+
8
+ from fastmcp.utilities.logging import get_logger
9
+
10
+ logger = get_logger(__name__)
11
+
12
+
13
+ class LedgerAdapter(ABC):
14
+ """Abstract base class for ledger adapters."""
15
+
16
+ @abstractmethod
17
+ async def submit_block(self, block_data: Dict[str, Any]) -> str:
18
+ """Submit a block to the external ledger.
19
+
20
+ Args:
21
+ block_data: The block data to submit
22
+
23
+ Returns:
24
+ Transaction ID or block hash from the external ledger
25
+ """
26
+ pass
27
+
28
+ @abstractmethod
29
+ async def verify_block(self, block_id: str) -> bool:
30
+ """Verify a block exists and is valid on the external ledger.
31
+
32
+ Args:
33
+ block_id: The block ID to verify
34
+
35
+ Returns:
36
+ True if block is valid, False otherwise
37
+ """
38
+ pass
39
+
40
+ @abstractmethod
41
+ async def get_block_proof(self, block_id: str) -> Optional[Dict[str, Any]]:
42
+ """Get a proof of block existence from the external ledger.
43
+
44
+ Args:
45
+ block_id: The block ID to get proof for
46
+
47
+ Returns:
48
+ Proof data or None if not found
49
+ """
50
+ pass
51
+
52
+
53
+ class HyperledgerAdapter(LedgerAdapter):
54
+ """Adapter for Hyperledger Fabric blockchain backend."""
55
+
56
+ def __init__(self,
57
+ network_config: str,
58
+ channel_name: str = "mcp-channel",
59
+ chaincode_name: str = "provenance-ledger",
60
+ peer_endpoint: str = "localhost:7051",
61
+ orderer_endpoint: str = "localhost:7050"):
62
+ """Initialize Hyperledger adapter.
63
+
64
+ Args:
65
+ network_config: Path to network configuration file
66
+ channel_name: Name of the Hyperledger channel
67
+ chaincode_name: Name of the deployed chaincode
68
+ peer_endpoint: Peer endpoint URL
69
+ orderer_endpoint: Orderer endpoint URL
70
+ """
71
+ self.network_config = network_config
72
+ self.channel_name = channel_name
73
+ self.chaincode_name = chaincode_name
74
+ self.peer_endpoint = peer_endpoint
75
+ self.orderer_endpoint = orderer_endpoint
76
+ self._client = None
77
+
78
+ async def _get_client(self):
79
+ """Get or create Hyperledger client."""
80
+ if self._client is None:
81
+ try:
82
+ # This would import the actual Hyperledger Fabric SDK
83
+ # from hfc.fabric import Client
84
+ # self._client = Client(net_profile=self.network_config)
85
+ logger.info("Hyperledger client initialized (stub implementation)")
86
+ self._client = "stub_client"
87
+ except ImportError:
88
+ logger.warning("Hyperledger Fabric SDK not available, using stub implementation")
89
+ self._client = "stub_client"
90
+ return self._client
91
+
92
+ async def submit_block(self, block_data: Dict[str, Any]) -> str:
93
+ """Submit a block to Hyperledger Fabric.
94
+
95
+ Args:
96
+ block_data: The block data to submit
97
+
98
+ Returns:
99
+ Transaction ID from Hyperledger
100
+ """
101
+ try:
102
+ client = await self._get_client()
103
+
104
+ # Prepare transaction data
105
+ transaction_data = {
106
+ "block_number": block_data.get("block_number"),
107
+ "merkle_root": block_data.get("merkle_root"),
108
+ "entry_count": block_data.get("entry_count"),
109
+ "timestamp": datetime.utcnow().isoformat(),
110
+ "entries": block_data.get("entries", [])
111
+ }
112
+
113
+ # In a real implementation, this would:
114
+ # 1. Create a transaction proposal
115
+ # 2. Send it to endorsing peers
116
+ # 3. Submit to ordering service
117
+ # 4. Return transaction ID
118
+
119
+ # Stub implementation
120
+ import hashlib
121
+ tx_data = json.dumps(transaction_data, sort_keys=True)
122
+ tx_id = hashlib.sha256(tx_data.encode()).hexdigest()
123
+
124
+ logger.info(f"Submitted block {block_data.get('block_number')} to Hyperledger (tx: {tx_id})")
125
+ return tx_id
126
+
127
+ except Exception as e:
128
+ logger.error(f"Failed to submit block to Hyperledger: {e}")
129
+ raise
130
+
131
+ async def verify_block(self, block_id: str) -> bool:
132
+ """Verify a block exists on Hyperledger Fabric.
133
+
134
+ Args:
135
+ block_id: The block ID to verify
136
+
137
+ Returns:
138
+ True if block is valid, False otherwise
139
+ """
140
+ try:
141
+ client = await self._get_client()
142
+
143
+ # In a real implementation, this would:
144
+ # 1. Query the blockchain for the block
145
+ # 2. Verify the block structure
146
+ # 3. Check block signatures
147
+
148
+ # Stub implementation - always return True for demo
149
+ logger.info(f"Verified block {block_id} on Hyperledger")
150
+ return True
151
+
152
+ except Exception as e:
153
+ logger.error(f"Failed to verify block {block_id}: {e}")
154
+ return False
155
+
156
+ async def get_block_proof(self, block_id: str) -> Optional[Dict[str, Any]]:
157
+ """Get a proof of block existence from Hyperledger Fabric.
158
+
159
+ Args:
160
+ block_id: The block ID to get proof for
161
+
162
+ Returns:
163
+ Proof data or None if not found
164
+ """
165
+ try:
166
+ client = await self._get_client()
167
+
168
+ # In a real implementation, this would:
169
+ # 1. Query the blockchain for block details
170
+ # 2. Get block header and signatures
171
+ # 3. Return proof data
172
+
173
+ # Stub implementation
174
+ proof_data = {
175
+ "block_id": block_id,
176
+ "block_hash": f"hyperledger_hash_{block_id}",
177
+ "block_number": int(block_id.split('_')[-1]) if '_' in block_id else 0,
178
+ "timestamp": datetime.utcnow().isoformat(),
179
+ "proof_type": "hyperledger_fabric",
180
+ "signatures": ["peer1_signature", "peer2_signature"],
181
+ "merkle_root": f"merkle_root_{block_id}"
182
+ }
183
+
184
+ logger.info(f"Retrieved block proof for {block_id} from Hyperledger")
185
+ return proof_data
186
+
187
+ except Exception as e:
188
+ logger.error(f"Failed to get block proof for {block_id}: {e}")
189
+ return None
190
+
191
+
192
+ class OmniSealAdapter(LedgerAdapter):
193
+ """Adapter for OmniSeal blockchain backend."""
194
+
195
+ def __init__(self,
196
+ api_endpoint: str = "https://api.omniseal.com",
197
+ api_key: str = None,
198
+ network_id: str = "mainnet"):
199
+ """Initialize OmniSeal adapter.
200
+
201
+ Args:
202
+ api_endpoint: OmniSeal API endpoint
203
+ api_key: API key for authentication
204
+ network_id: Network ID to use
205
+ """
206
+ self.api_endpoint = api_endpoint
207
+ self.api_key = api_key
208
+ self.network_id = network_id
209
+ self._session = None
210
+
211
+ async def _get_session(self):
212
+ """Get or create HTTP session."""
213
+ if self._session is None:
214
+ try:
215
+ import aiohttp
216
+ headers = {}
217
+ if self.api_key:
218
+ headers["Authorization"] = f"Bearer {self.api_key}"
219
+ self._session = aiohttp.ClientSession(
220
+ base_url=self.api_endpoint,
221
+ headers=headers
222
+ )
223
+ except ImportError:
224
+ logger.warning("aiohttp not available, using stub implementation")
225
+ self._session = "stub_session"
226
+ return self._session
227
+
228
+ async def submit_block(self, block_data: Dict[str, Any]) -> str:
229
+ """Submit a block to OmniSeal.
230
+
231
+ Args:
232
+ block_data: The block data to submit
233
+
234
+ Returns:
235
+ Transaction ID from OmniSeal
236
+ """
237
+ try:
238
+ session = await self._get_session()
239
+
240
+ # Prepare submission data
241
+ submission_data = {
242
+ "network_id": self.network_id,
243
+ "block_data": block_data,
244
+ "timestamp": datetime.utcnow().isoformat()
245
+ }
246
+
247
+ # In a real implementation, this would:
248
+ # 1. Send POST request to OmniSeal API
249
+ # 2. Handle response and errors
250
+ # 3. Return transaction ID
251
+
252
+ # Stub implementation
253
+ import hashlib
254
+ tx_data = json.dumps(submission_data, sort_keys=True)
255
+ tx_id = hashlib.sha256(tx_data.encode()).hexdigest()
256
+
257
+ logger.info(f"Submitted block {block_data.get('block_number')} to OmniSeal (tx: {tx_id})")
258
+ return tx_id
259
+
260
+ except Exception as e:
261
+ logger.error(f"Failed to submit block to OmniSeal: {e}")
262
+ raise
263
+
264
+ async def verify_block(self, block_id: str) -> bool:
265
+ """Verify a block exists on OmniSeal.
266
+
267
+ Args:
268
+ block_id: The block ID to verify
269
+
270
+ Returns:
271
+ True if block is valid, False otherwise
272
+ """
273
+ try:
274
+ session = await self._get_session()
275
+
276
+ # In a real implementation, this would:
277
+ # 1. Send GET request to OmniSeal API
278
+ # 2. Check response status
279
+ # 3. Verify block data
280
+
281
+ # Stub implementation
282
+ logger.info(f"Verified block {block_id} on OmniSeal")
283
+ return True
284
+
285
+ except Exception as e:
286
+ logger.error(f"Failed to verify block {block_id}: {e}")
287
+ return False
288
+
289
+ async def get_block_proof(self, block_id: str) -> Optional[Dict[str, Any]]:
290
+ """Get a proof of block existence from OmniSeal.
291
+
292
+ Args:
293
+ block_id: The block ID to get proof for
294
+
295
+ Returns:
296
+ Proof data or None if not found
297
+ """
298
+ try:
299
+ session = await self._get_session()
300
+
301
+ # In a real implementation, this would:
302
+ # 1. Send GET request to OmniSeal API
303
+ # 2. Parse response data
304
+ # 3. Return proof information
305
+
306
+ # Stub implementation
307
+ proof_data = {
308
+ "block_id": block_id,
309
+ "block_hash": f"omniseal_hash_{block_id}",
310
+ "block_number": int(block_id.split('_')[-1]) if '_' in block_id else 0,
311
+ "timestamp": datetime.utcnow().isoformat(),
312
+ "proof_type": "omniseal",
313
+ "network_id": self.network_id,
314
+ "merkle_root": f"merkle_root_{block_id}"
315
+ }
316
+
317
+ logger.info(f"Retrieved block proof for {block_id} from OmniSeal")
318
+ return proof_data
319
+
320
+ except Exception as e:
321
+ logger.error(f"Failed to get block proof for {block_id}: {e}")
322
+ return None
323
+
324
+
325
+ class StubAdapter(LedgerAdapter):
326
+ """Stub adapter for testing and development."""
327
+
328
+ def __init__(self):
329
+ """Initialize stub adapter."""
330
+ self.submitted_blocks = {}
331
+ self.block_proofs = {}
332
+
333
+ async def submit_block(self, block_data: Dict[str, Any]) -> str:
334
+ """Submit a block to the stub storage.
335
+
336
+ Args:
337
+ block_data: The block data to submit
338
+
339
+ Returns:
340
+ Generated block ID
341
+ """
342
+ import hashlib
343
+ block_id = f"stub_block_{len(self.submitted_blocks) + 1}"
344
+ self.submitted_blocks[block_id] = block_data
345
+ logger.info(f"Submitted block {block_data.get('block_number')} to stub storage (id: {block_id})")
346
+ return block_id
347
+
348
+ async def verify_block(self, block_id: str) -> bool:
349
+ """Verify a block exists in stub storage.
350
+
351
+ Args:
352
+ block_id: The block ID to verify
353
+
354
+ Returns:
355
+ True if block exists, False otherwise
356
+ """
357
+ exists = block_id in self.submitted_blocks
358
+ logger.info(f"Verified block {block_id} in stub storage: {exists}")
359
+ return exists
360
+
361
+ async def get_block_proof(self, block_id: str) -> Optional[Dict[str, Any]]:
362
+ """Get a proof of block existence from stub storage.
363
+
364
+ Args:
365
+ block_id: The block ID to get proof for
366
+
367
+ Returns:
368
+ Proof data or None if not found
369
+ """
370
+ if block_id not in self.submitted_blocks:
371
+ return None
372
+
373
+ block_data = self.submitted_blocks[block_id]
374
+ proof_data = {
375
+ "block_id": block_id,
376
+ "block_hash": f"stub_hash_{block_id}",
377
+ "block_number": block_data.get("block_number", 0),
378
+ "timestamp": datetime.utcnow().isoformat(),
379
+ "proof_type": "stub",
380
+ "merkle_root": block_data.get("merkle_root", "")
381
+ }
382
+
383
+ logger.info(f"Retrieved block proof for {block_id} from stub storage")
384
+ return proof_data
src/fastmcp/ledger/ledger.py ADDED
@@ -0,0 +1,504 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Core ledger implementation with hash-linked entries and tamper-evident properties."""
2
+
3
+ import hashlib
4
+ import json
5
+ from datetime import datetime
6
+ from enum import Enum
7
+ from typing import Any, Dict, List, Optional
8
+ from uuid import UUID, uuid4
9
+
10
+ from pydantic import BaseModel, Field
11
+ from sqlmodel import SQLModel, Field as SQLField, Relationship
12
+
13
+ from fastmcp.utilities.logging import get_logger
14
+
15
+ logger = get_logger(__name__)
16
+
17
+
18
+ class EventType(str, Enum):
19
+ """Types of events that can be recorded in the ledger."""
20
+ TOOL_CALL = "tool_call"
21
+ POLICY_DECISION = "policy_decision"
22
+ DATA_FLOW = "data_flow"
23
+ CONTRACT_ACTION = "contract_action"
24
+ AUTHENTICATION = "authentication"
25
+ AUTHORIZATION = "authorization"
26
+ SYSTEM_EVENT = "system_event"
27
+
28
+
29
+ class LedgerEvent(BaseModel):
30
+ """A ledger event with structured data."""
31
+
32
+ event_type: EventType = Field(..., description="Type of event")
33
+ actor_id: str = Field(..., description="ID of the actor performing the action")
34
+ resource_id: Optional[str] = Field(default=None, description="ID of the resource being acted upon")
35
+ action: str = Field(..., description="Action being performed")
36
+ metadata: Dict[str, Any] = Field(default_factory=dict, description="Additional event metadata")
37
+ timestamp: datetime = Field(default_factory=datetime.utcnow, description="Event timestamp")
38
+ data_hash: Optional[str] = Field(default=None, description="Hash of associated data")
39
+
40
+ def get_content_hash(self) -> str:
41
+ """Get SHA-256 hash of event content for integrity verification."""
42
+ content = {
43
+ "event_type": self.event_type,
44
+ "actor_id": self.actor_id,
45
+ "resource_id": self.resource_id,
46
+ "action": self.action,
47
+ "metadata": self.metadata,
48
+ "data_hash": self.data_hash
49
+ }
50
+ content_str = json.dumps(content, sort_keys=True, default=str)
51
+ return hashlib.sha256(content_str.encode()).hexdigest()
52
+
53
+
54
+ class LedgerEntry(SQLModel, table=True):
55
+ """A single entry in the provenance ledger with hash chaining."""
56
+
57
+ __tablename__ = "ledger_entries"
58
+
59
+ # Primary fields
60
+ id: UUID = SQLField(default_factory=uuid4, primary_key=True)
61
+ sequence_number: int = SQLField(..., index=True, description="Sequential entry number")
62
+
63
+ # Event data
64
+ event_data: str = SQLField(..., description="JSON-encoded event data")
65
+
66
+ # Hash chaining
67
+ previous_hash: Optional[str] = SQLField(default=None, description="Hash of previous entry")
68
+ entry_hash: str = SQLField(..., index=True, description="Hash of this entry")
69
+
70
+ # Block information
71
+ block_id: Optional[UUID] = SQLField(default=None, foreign_key="ledger_blocks.id", index=True)
72
+
73
+ # Timestamps
74
+ created_at: datetime = SQLField(default_factory=datetime.utcnow, index=True)
75
+
76
+ # Verification
77
+ is_verified: bool = SQLField(default=True, description="Whether entry has been verified")
78
+ verification_timestamp: Optional[datetime] = SQLField(default=None)
79
+
80
+ def get_event(self) -> LedgerEvent:
81
+ """Get parsed event from JSON."""
82
+ try:
83
+ event_data = json.loads(self.event_data)
84
+ return LedgerEvent(**event_data)
85
+ except (json.JSONDecodeError, ValueError) as e:
86
+ logger.error(f"Failed to parse event data: {e}")
87
+ raise ValueError(f"Invalid event data: {e}")
88
+
89
+ def set_event(self, event: LedgerEvent) -> None:
90
+ """Set event as JSON."""
91
+ self.event_data = json.dumps(event.model_dump(), default=str)
92
+
93
+ def calculate_hash(self) -> str:
94
+ """Calculate hash of this entry including previous hash."""
95
+ content = {
96
+ "sequence_number": self.sequence_number,
97
+ "event_data": self.event_data,
98
+ "previous_hash": self.previous_hash,
99
+ "created_at": self.created_at.isoformat()
100
+ }
101
+ content_str = json.dumps(content, sort_keys=True, default=str)
102
+ return hashlib.sha256(content_str.encode()).hexdigest()
103
+
104
+ def verify_integrity(self) -> bool:
105
+ """Verify that the entry hash matches the calculated hash."""
106
+ calculated_hash = self.calculate_hash()
107
+ return calculated_hash == self.entry_hash
108
+
109
+
110
+ class LedgerBlock(SQLModel, table=True):
111
+ """A block of ledger entries with Merkle tree root."""
112
+
113
+ __tablename__ = "ledger_blocks"
114
+
115
+ # Primary fields
116
+ id: UUID = SQLField(default_factory=uuid4, primary_key=True)
117
+ block_number: int = SQLField(..., index=True, description="Sequential block number")
118
+
119
+ # Block metadata
120
+ entry_count: int = SQLField(..., description="Number of entries in this block")
121
+ first_entry_sequence: int = SQLField(..., description="Sequence number of first entry")
122
+ last_entry_sequence: int = SQLField(..., description="Sequence number of last entry")
123
+
124
+ # Merkle tree
125
+ merkle_root: str = SQLField(..., description="Merkle tree root hash")
126
+ merkle_tree_data: str = SQLField(default="[]", description="JSON-encoded Merkle tree data")
127
+
128
+ # Timestamps
129
+ created_at: datetime = SQLField(default_factory=datetime.utcnow, index=True)
130
+ sealed_at: Optional[datetime] = SQLField(default=None, description="When block was sealed")
131
+
132
+ # Verification
133
+ is_verified: bool = SQLField(default=True, description="Whether block has been verified")
134
+ verification_timestamp: Optional[datetime] = SQLField(default=None)
135
+
136
+ def get_merkle_tree_data(self) -> List[Dict[str, Any]]:
137
+ """Get parsed Merkle tree data from JSON."""
138
+ try:
139
+ return json.loads(self.merkle_tree_data)
140
+ except json.JSONDecodeError:
141
+ return []
142
+
143
+ def set_merkle_tree_data(self, tree_data: List[Dict[str, Any]]) -> None:
144
+ """Set Merkle tree data as JSON."""
145
+ self.merkle_tree_data = json.dumps(tree_data)
146
+
147
+ def verify_integrity(self, entries: List[LedgerEntry]) -> bool:
148
+ """Verify that the Merkle root matches the entries."""
149
+ if len(entries) != self.entry_count:
150
+ return False
151
+
152
+ # Calculate Merkle root from entries
153
+ from .merkle import MerkleTree
154
+ entry_hashes = [entry.entry_hash for entry in entries]
155
+ merkle_tree = MerkleTree(entry_hashes)
156
+ calculated_root = merkle_tree.get_root()
157
+
158
+ return calculated_root == self.merkle_root
159
+
160
+
161
+ class ProvenanceLedger:
162
+ """Main ledger class for managing provenance entries with tamper-evident properties."""
163
+
164
+ def __init__(self, database_url: str = "sqlite:///ledger.db"):
165
+ """Initialize the provenance ledger.
166
+
167
+ Args:
168
+ database_url: Database connection URL
169
+ """
170
+ from sqlmodel import create_engine, Session
171
+ self.engine = create_engine(database_url, echo=False)
172
+ self._create_tables()
173
+ self._current_sequence = self._get_next_sequence_number()
174
+ self._current_block = None
175
+ self._block_size = 100 # Entries per block
176
+
177
+ def _create_tables(self):
178
+ """Create database tables."""
179
+ try:
180
+ LedgerEntry.metadata.create_all(self.engine)
181
+ LedgerBlock.metadata.create_all(self.engine)
182
+ logger.info("Ledger database tables created/verified")
183
+ except Exception as e:
184
+ logger.error(f"Failed to create ledger tables: {e}")
185
+ raise
186
+
187
+ def _get_next_sequence_number(self) -> int:
188
+ """Get the next sequence number for entries."""
189
+ from sqlmodel import Session, select, func
190
+ with Session(self.engine) as session:
191
+ result = session.exec(select(func.max(LedgerEntry.sequence_number))).first()
192
+ return (result or 0) + 1
193
+
194
+ def _get_next_block_number(self) -> int:
195
+ """Get the next block number."""
196
+ from sqlmodel import Session, select, func
197
+ with Session(self.engine) as session:
198
+ result = session.exec(select(func.max(LedgerBlock.block_number))).first()
199
+ return (result or 0) + 1
200
+
201
+ def append_event(self, event: LedgerEvent) -> LedgerEntry:
202
+ """Append a new event to the ledger.
203
+
204
+ Args:
205
+ event: The event to append
206
+
207
+ Returns:
208
+ The created ledger entry
209
+
210
+ Raises:
211
+ ValueError: If event is invalid
212
+ """
213
+ try:
214
+ from sqlmodel import Session, select
215
+
216
+ with Session(self.engine) as session:
217
+ # Get previous entry hash
218
+ previous_hash = None
219
+ if self._current_sequence > 1:
220
+ prev_entry = session.exec(
221
+ select(LedgerEntry).where(
222
+ LedgerEntry.sequence_number == self._current_sequence - 1
223
+ )
224
+ ).first()
225
+ if prev_entry:
226
+ previous_hash = prev_entry.entry_hash
227
+
228
+ # Create new entry
229
+ entry = LedgerEntry(
230
+ sequence_number=self._current_sequence,
231
+ previous_hash=previous_hash
232
+ )
233
+ entry.set_event(event)
234
+ entry.entry_hash = entry.calculate_hash()
235
+
236
+ # Add to current block or create new block
237
+ if self._current_block is None or self._should_seal_block():
238
+ self._current_block = self._create_new_block(session)
239
+
240
+ entry.block_id = self._current_block.id
241
+
242
+ # Save entry
243
+ session.add(entry)
244
+ session.commit()
245
+ session.refresh(entry)
246
+
247
+ # Update block entry count
248
+ self._current_block.entry_count += 1
249
+ self._current_block.last_entry_sequence = entry.sequence_number
250
+ session.add(self._current_block)
251
+
252
+ # Check if block should be sealed
253
+ if self._should_seal_block():
254
+ self._seal_block(session)
255
+
256
+ self._current_sequence += 1
257
+
258
+ logger.info(f"Appended event {entry.id} to ledger (sequence: {entry.sequence_number})")
259
+ return entry
260
+
261
+ except Exception as e:
262
+ logger.error(f"Failed to append event: {e}")
263
+ raise ValueError(f"Failed to append event: {e}")
264
+
265
+ def _should_seal_block(self) -> bool:
266
+ """Check if current block should be sealed."""
267
+ return (self._current_block and
268
+ self._current_block.entry_count >= self._block_size)
269
+
270
+ def _create_new_block(self, session) -> LedgerBlock:
271
+ """Create a new block."""
272
+ block_number = self._get_next_block_number()
273
+ block = LedgerBlock(
274
+ block_number=block_number,
275
+ entry_count=0,
276
+ first_entry_sequence=self._current_sequence,
277
+ last_entry_sequence=self._current_sequence - 1,
278
+ merkle_root="", # Will be set when sealed
279
+ merkle_tree_data="[]"
280
+ )
281
+ session.add(block)
282
+ session.commit()
283
+ session.refresh(block)
284
+ return block
285
+
286
+ def _seal_block(self, session):
287
+ """Seal the current block with Merkle tree."""
288
+ if not self._current_block:
289
+ return
290
+
291
+ from sqlmodel import select
292
+
293
+ # Get all entries in this block
294
+ entries = session.exec(
295
+ select(LedgerEntry).where(
296
+ LedgerEntry.block_id == self._current_block.id
297
+ ).order_by(LedgerEntry.sequence_number)
298
+ ).all()
299
+
300
+ if not entries:
301
+ return
302
+
303
+ # Create Merkle tree
304
+ from .merkle import MerkleTree
305
+ entry_hashes = [entry.entry_hash for entry in entries]
306
+ merkle_tree = MerkleTree(entry_hashes)
307
+
308
+ # Update block with Merkle root
309
+ self._current_block.merkle_root = merkle_tree.get_root()
310
+ self._current_block.merkle_tree_data = json.dumps(merkle_tree.get_tree_data())
311
+ self._current_block.sealed_at = datetime.utcnow()
312
+ self._current_block.is_verified = True
313
+ self._current_block.verification_timestamp = datetime.utcnow()
314
+
315
+ session.add(self._current_block)
316
+ session.commit()
317
+
318
+ logger.info(f"Sealed block {self._current_block.block_number} with {len(entries)} entries")
319
+ self._current_block = None
320
+
321
+ def seal_current_block(self) -> bool:
322
+ """Manually seal the current block if it exists.
323
+
324
+ Returns:
325
+ True if block was sealed, False if no current block
326
+ """
327
+ if not self._current_block:
328
+ return False
329
+
330
+ try:
331
+ from sqlmodel import Session
332
+
333
+ with Session(self.engine) as session:
334
+ self._seal_block(session)
335
+ return True
336
+ except Exception as e:
337
+ logger.error(f"Failed to seal current block: {e}")
338
+ return False
339
+
340
+ def get_entry(self, sequence_number: int) -> Optional[LedgerEntry]:
341
+ """Get a ledger entry by sequence number.
342
+
343
+ Args:
344
+ sequence_number: The sequence number of the entry
345
+
346
+ Returns:
347
+ The ledger entry or None if not found
348
+ """
349
+ from sqlmodel import Session, select
350
+
351
+ with Session(self.engine) as session:
352
+ return session.exec(
353
+ select(LedgerEntry).where(
354
+ LedgerEntry.sequence_number == sequence_number
355
+ )
356
+ ).first()
357
+
358
+ def get_block(self, block_number: int) -> Optional[LedgerBlock]:
359
+ """Get a ledger block by block number.
360
+
361
+ Args:
362
+ block_number: The block number
363
+
364
+ Returns:
365
+ The ledger block or None if not found
366
+ """
367
+ from sqlmodel import Session, select
368
+
369
+ with Session(self.engine) as session:
370
+ return session.exec(
371
+ select(LedgerBlock).where(
372
+ LedgerBlock.block_number == block_number
373
+ )
374
+ ).first()
375
+
376
+ def get_block_entries(self, block_number: int) -> List[LedgerEntry]:
377
+ """Get all entries in a block.
378
+
379
+ Args:
380
+ block_number: The block number
381
+
382
+ Returns:
383
+ List of ledger entries in the block
384
+ """
385
+ from sqlmodel import Session, select
386
+
387
+ with Session(self.engine) as session:
388
+ block = self.get_block(block_number)
389
+ if not block:
390
+ return []
391
+
392
+ return session.exec(
393
+ select(LedgerEntry).where(
394
+ LedgerEntry.block_id == block.id
395
+ ).order_by(LedgerEntry.sequence_number)
396
+ ).all()
397
+
398
+ def verify_block_integrity(self, block_number: int) -> bool:
399
+ """Verify the integrity of a block and its entries.
400
+
401
+ Args:
402
+ block_number: The block number to verify
403
+
404
+ Returns:
405
+ True if block is valid, False otherwise
406
+ """
407
+ try:
408
+ block = self.get_block(block_number)
409
+ if not block:
410
+ return False
411
+
412
+ entries = self.get_block_entries(block_number)
413
+ if not entries:
414
+ return False
415
+
416
+ # Verify each entry
417
+ for entry in entries:
418
+ if not entry.verify_integrity():
419
+ logger.warning(f"Entry {entry.sequence_number} failed integrity check")
420
+ return False
421
+
422
+ # Verify block Merkle root
423
+ if not block.verify_integrity(entries):
424
+ logger.warning(f"Block {block_number} failed Merkle root verification")
425
+ return False
426
+
427
+ return True
428
+
429
+ except Exception as e:
430
+ logger.error(f"Failed to verify block {block_number}: {e}")
431
+ return False
432
+
433
+ def verify_chain_integrity(self, start_sequence: int = 1, end_sequence: Optional[int] = None) -> bool:
434
+ """Verify the integrity of the entire chain or a range of entries.
435
+
436
+ Args:
437
+ start_sequence: Starting sequence number (default: 1)
438
+ end_sequence: Ending sequence number (default: None for all)
439
+
440
+ Returns:
441
+ True if chain is valid, False otherwise
442
+ """
443
+ try:
444
+ from sqlmodel import Session, select
445
+
446
+ with Session(self.engine) as session:
447
+ query = select(LedgerEntry).where(
448
+ LedgerEntry.sequence_number >= start_sequence
449
+ ).order_by(LedgerEntry.sequence_number)
450
+
451
+ if end_sequence:
452
+ query = query.where(LedgerEntry.sequence_number <= end_sequence)
453
+
454
+ entries = session.exec(query).all()
455
+
456
+ if not entries:
457
+ return True
458
+
459
+ # Verify each entry and hash chain
460
+ previous_hash = None
461
+ for entry in entries:
462
+ # Verify entry integrity
463
+ if not entry.verify_integrity():
464
+ logger.warning(f"Entry {entry.sequence_number} failed integrity check")
465
+ return False
466
+
467
+ # Verify hash chain
468
+ if previous_hash and entry.previous_hash != previous_hash:
469
+ logger.warning(f"Hash chain broken at entry {entry.sequence_number}")
470
+ return False
471
+
472
+ previous_hash = entry.entry_hash
473
+
474
+ return True
475
+
476
+ except Exception as e:
477
+ logger.error(f"Failed to verify chain integrity: {e}")
478
+ return False
479
+
480
+ def get_ledger_statistics(self) -> Dict[str, Any]:
481
+ """Get statistics about the ledger.
482
+
483
+ Returns:
484
+ Dictionary with ledger statistics
485
+ """
486
+ from sqlmodel import Session, select, func
487
+
488
+ with Session(self.engine) as session:
489
+ total_entries = session.exec(select(func.count(LedgerEntry.id))).first() or 0
490
+ total_blocks = session.exec(select(func.count(LedgerBlock.id))).first() or 0
491
+ sealed_blocks = session.exec(
492
+ select(func.count(LedgerBlock.id)).where(
493
+ LedgerBlock.sealed_at.isnot(None)
494
+ )
495
+ ).first() or 0
496
+
497
+ return {
498
+ "total_entries": total_entries,
499
+ "total_blocks": total_blocks,
500
+ "sealed_blocks": sealed_blocks,
501
+ "unsealed_blocks": total_blocks - sealed_blocks,
502
+ "current_sequence": self._current_sequence - 1,
503
+ "block_size": self._block_size
504
+ }
src/fastmcp/ledger/merkle.py ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Merkle tree implementation for ledger integrity verification."""
2
+
3
+ import hashlib
4
+ from typing import List, Dict, Any, Optional
5
+
6
+
7
+ class MerkleProof:
8
+ """A Merkle proof for verifying an entry's inclusion in a Merkle tree."""
9
+
10
+ def __init__(self, leaf_hash: str, path: List[Dict[str, str]], root_hash: str):
11
+ """Initialize a Merkle proof.
12
+
13
+ Args:
14
+ leaf_hash: Hash of the leaf node being proven
15
+ path: List of path elements with 'hash' and 'position' ('left' or 'right')
16
+ root_hash: The root hash of the Merkle tree
17
+ """
18
+ self.leaf_hash = leaf_hash
19
+ self.path = path
20
+ self.root_hash = root_hash
21
+
22
+ def verify(self) -> bool:
23
+ """Verify that this proof is valid.
24
+
25
+ Returns:
26
+ True if the proof is valid, False otherwise
27
+ """
28
+ current_hash = self.leaf_hash
29
+
30
+ for path_element in self.path:
31
+ sibling_hash = path_element['hash']
32
+ position = path_element['position']
33
+
34
+ if position == 'left':
35
+ # Current hash is on the right, sibling on the left
36
+ combined = sibling_hash + current_hash
37
+ else: # position == 'right'
38
+ # Current hash is on the left, sibling on the right
39
+ combined = current_hash + sibling_hash
40
+
41
+ current_hash = hashlib.sha256(combined.encode()).hexdigest()
42
+
43
+ return current_hash == self.root_hash
44
+
45
+
46
+ class MerkleTree:
47
+ """A Merkle tree for efficient integrity verification of multiple entries."""
48
+
49
+ def __init__(self, leaf_hashes: List[str]):
50
+ """Initialize a Merkle tree from a list of leaf hashes.
51
+
52
+ Args:
53
+ leaf_hashes: List of SHA-256 hashes of the leaf nodes
54
+ """
55
+ if not leaf_hashes:
56
+ raise ValueError("Cannot create Merkle tree with empty leaf list")
57
+
58
+ self.leaf_hashes = leaf_hashes.copy()
59
+ self.tree_data = []
60
+ self.root_hash = self._build_tree()
61
+
62
+ def _build_tree(self) -> str:
63
+ """Build the Merkle tree and return the root hash.
64
+
65
+ Returns:
66
+ The root hash of the Merkle tree
67
+ """
68
+ if len(self.leaf_hashes) == 1:
69
+ return self.leaf_hashes[0]
70
+
71
+ # Ensure we have an even number of leaves by duplicating the last one if necessary
72
+ current_level = self.leaf_hashes.copy()
73
+ tree_levels = [current_level.copy()]
74
+
75
+ while len(current_level) > 1:
76
+ next_level = []
77
+
78
+ # Process pairs of nodes
79
+ for i in range(0, len(current_level), 2):
80
+ left_hash = current_level[i]
81
+ right_hash = current_level[i + 1] if i + 1 < len(current_level) else current_level[i]
82
+
83
+ # Combine and hash
84
+ combined = left_hash + right_hash
85
+ parent_hash = hashlib.sha256(combined.encode()).hexdigest()
86
+ next_level.append(parent_hash)
87
+
88
+ tree_levels.append(next_level.copy())
89
+ current_level = next_level
90
+
91
+ # Store tree data for proof generation
92
+ self.tree_data = tree_levels
93
+ return current_level[0]
94
+
95
+ def get_root(self) -> str:
96
+ """Get the root hash of the Merkle tree.
97
+
98
+ Returns:
99
+ The root hash
100
+ """
101
+ return self.root_hash
102
+
103
+ def get_tree_data(self) -> List[List[str]]:
104
+ """Get the complete tree data structure.
105
+
106
+ Returns:
107
+ List of levels, where each level is a list of hashes
108
+ """
109
+ return self.tree_data
110
+
111
+ def generate_proof(self, leaf_hash: str) -> Optional[MerkleProof]:
112
+ """Generate a Merkle proof for a specific leaf hash.
113
+
114
+ Args:
115
+ leaf_hash: The hash of the leaf to prove
116
+
117
+ Returns:
118
+ A MerkleProof object or None if the leaf is not found
119
+ """
120
+ try:
121
+ leaf_index = self.leaf_hashes.index(leaf_hash)
122
+ except ValueError:
123
+ return None
124
+
125
+ if len(self.leaf_hashes) == 1:
126
+ # Single leaf case
127
+ return MerkleProof(leaf_hash, [], self.root_hash)
128
+
129
+ path = []
130
+ current_index = leaf_index
131
+
132
+ # Traverse up the tree
133
+ for level in range(len(self.tree_data) - 1):
134
+ current_level = self.tree_data[level]
135
+
136
+ # Find sibling
137
+ if current_index % 2 == 0: # Even index, sibling is on the right
138
+ sibling_index = current_index + 1
139
+ position = 'right'
140
+ else: # Odd index, sibling is on the left
141
+ sibling_index = current_index - 1
142
+ position = 'left'
143
+
144
+ # Add sibling to path if it exists
145
+ if sibling_index < len(current_level):
146
+ path.append({
147
+ 'hash': current_level[sibling_index],
148
+ 'position': position
149
+ })
150
+
151
+ # Move to parent level
152
+ current_index = current_index // 2
153
+
154
+ return MerkleProof(leaf_hash, path, self.root_hash)
155
+
156
+ def verify_proof(self, proof: MerkleProof) -> bool:
157
+ """Verify a Merkle proof.
158
+
159
+ Args:
160
+ proof: The MerkleProof to verify
161
+
162
+ Returns:
163
+ True if the proof is valid, False otherwise
164
+ """
165
+ return proof.verify()
166
+
167
+ def verify_leaf(self, leaf_hash: str) -> bool:
168
+ """Verify that a leaf hash is part of this Merkle tree.
169
+
170
+ Args:
171
+ leaf_hash: The leaf hash to verify
172
+
173
+ Returns:
174
+ True if the leaf is part of the tree, False otherwise
175
+ """
176
+ return leaf_hash in self.leaf_hashes
177
+
178
+ def get_leaf_count(self) -> int:
179
+ """Get the number of leaf nodes in the tree.
180
+
181
+ Returns:
182
+ The number of leaf nodes
183
+ """
184
+ return len(self.leaf_hashes)
185
+
186
+ def get_leaf_hashes(self) -> List[str]:
187
+ """Get the list of leaf hashes.
188
+
189
+ Returns:
190
+ List of leaf hashes
191
+ """
192
+ return self.leaf_hashes.copy()
193
+
194
+ def get_tree_height(self) -> int:
195
+ """Get the height of the Merkle tree.
196
+
197
+ Returns:
198
+ The height of the tree (number of levels)
199
+ """
200
+ return len(self.tree_data) if self.tree_data else 1
201
+
202
+ def to_dict(self) -> Dict[str, Any]:
203
+ """Convert the Merkle tree to a dictionary representation.
204
+
205
+ Returns:
206
+ Dictionary representation of the tree
207
+ """
208
+ return {
209
+ "root_hash": self.root_hash,
210
+ "leaf_count": self.get_leaf_count(),
211
+ "tree_height": self.get_tree_height(),
212
+ "tree_data": self.tree_data,
213
+ "leaf_hashes": self.leaf_hashes
214
+ }
215
+
216
+ @classmethod
217
+ def from_dict(cls, data: Dict[str, Any]) -> "MerkleTree":
218
+ """Create a MerkleTree from a dictionary representation.
219
+
220
+ Args:
221
+ data: Dictionary representation of the tree
222
+
223
+ Returns:
224
+ A MerkleTree instance
225
+ """
226
+ tree = cls(data["leaf_hashes"])
227
+ tree.tree_data = data["tree_data"]
228
+ tree.root_hash = data["root_hash"]
229
+ return tree
230
+
231
+
232
+ def verify_merkle_proof(leaf_hash: str, proof_path: List[Dict[str, str]], root_hash: str) -> bool:
233
+ """Verify a Merkle proof without creating a MerkleTree instance.
234
+
235
+ Args:
236
+ leaf_hash: Hash of the leaf being proven
237
+ proof_path: List of path elements with 'hash' and 'position'
238
+ root_hash: The expected root hash
239
+
240
+ Returns:
241
+ True if the proof is valid, False otherwise
242
+ """
243
+ proof = MerkleProof(leaf_hash, proof_path, root_hash)
244
+ return proof.verify()
src/fastmcp/server/ledger_routes.py ADDED
@@ -0,0 +1,498 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ledger management HTTP routes."""
2
+
3
+ from typing import Any, Dict, List, Optional
4
+ from uuid import UUID
5
+
6
+ from starlette.requests import Request
7
+ from starlette.responses import JSONResponse
8
+ from starlette.routing import Route
9
+
10
+ from fastmcp.ledger import ProvenanceLedger, LedgerEvent, EventType, MerkleProof, LedgerBlock
11
+ from fastmcp.utilities.logging import get_logger
12
+
13
+ logger = get_logger(__name__)
14
+
15
+
16
+ async def append_event_endpoint(request: Request) -> JSONResponse:
17
+ """HTTP endpoint for appending events to the ledger.
18
+
19
+ Expected JSON body:
20
+ {
21
+ "event_type": "tool_call",
22
+ "actor_id": "user123",
23
+ "resource_id": "resource456",
24
+ "action": "execute_tool",
25
+ "metadata": {...},
26
+ "data_hash": "sha256_hash_of_data"
27
+ }
28
+ """
29
+ try:
30
+ # Parse request body
31
+ body = await request.json()
32
+
33
+ # Get ledger from request state
34
+ ledger: ProvenanceLedger = request.app.state.ledger
35
+
36
+ # Create ledger event
37
+ event = LedgerEvent(**body)
38
+
39
+ # Append to ledger
40
+ entry = ledger.append_event(event)
41
+
42
+ return JSONResponse(
43
+ status_code=201,
44
+ content={
45
+ "entry_id": str(entry.id),
46
+ "sequence_number": entry.sequence_number,
47
+ "entry_hash": entry.entry_hash,
48
+ "block_id": str(entry.block_id) if entry.block_id else None,
49
+ "created_at": entry.created_at.isoformat()
50
+ }
51
+ )
52
+
53
+ except ValueError as e:
54
+ logger.error(f"Invalid event data: {e}")
55
+ return JSONResponse(
56
+ status_code=400,
57
+ content={
58
+ "error": "Invalid event data",
59
+ "reason": str(e)
60
+ }
61
+ )
62
+ except Exception as e:
63
+ logger.error(f"Failed to append event: {e}")
64
+ return JSONResponse(
65
+ status_code=500,
66
+ content={
67
+ "error": "Failed to append event",
68
+ "reason": str(e)
69
+ }
70
+ )
71
+
72
+
73
+ async def verify_block_endpoint(request: Request) -> JSONResponse:
74
+ """HTTP endpoint for verifying block integrity.
75
+
76
+ Path parameter: block_number (int)
77
+ """
78
+ try:
79
+ # Get block number from path
80
+ block_number = int(request.path_params["block"])
81
+
82
+ # Get ledger from request state
83
+ ledger: ProvenanceLedger = request.app.state.ledger
84
+
85
+ # Verify block integrity
86
+ is_valid = ledger.verify_block_integrity(block_number)
87
+
88
+ if not is_valid:
89
+ return JSONResponse(
90
+ status_code=400,
91
+ content={
92
+ "block_number": block_number,
93
+ "verified": False,
94
+ "error": "Block integrity verification failed"
95
+ }
96
+ )
97
+
98
+ # Get block details
99
+ block = ledger.get_block(block_number)
100
+ entries = ledger.get_block_entries(block_number)
101
+
102
+ return JSONResponse(
103
+ status_code=200,
104
+ content={
105
+ "block_number": block_number,
106
+ "verified": True,
107
+ "block_id": str(block.id) if block else None,
108
+ "entry_count": len(entries),
109
+ "merkle_root": block.merkle_root if block else None,
110
+ "sealed_at": block.sealed_at.isoformat() if block and block.sealed_at else None,
111
+ "verification_timestamp": block.verification_timestamp.isoformat() if block and block.verification_timestamp else None
112
+ }
113
+ )
114
+
115
+ except ValueError as e:
116
+ return JSONResponse(
117
+ status_code=400,
118
+ content={
119
+ "error": "Invalid block number",
120
+ "reason": str(e)
121
+ }
122
+ )
123
+ except Exception as e:
124
+ logger.error(f"Failed to verify block: {e}")
125
+ return JSONResponse(
126
+ status_code=500,
127
+ content={
128
+ "error": "Failed to verify block",
129
+ "reason": str(e)
130
+ }
131
+ )
132
+
133
+
134
+ async def get_entry_endpoint(request: Request) -> JSONResponse:
135
+ """HTTP endpoint for getting a ledger entry by sequence number.
136
+
137
+ Path parameter: sequence_number (int)
138
+ """
139
+ try:
140
+ # Get sequence number from path
141
+ sequence_number = int(request.path_params["sequence"])
142
+
143
+ # Get ledger from request state
144
+ ledger: ProvenanceLedger = request.app.state.ledger
145
+
146
+ # Get entry
147
+ entry = ledger.get_entry(sequence_number)
148
+
149
+ if not entry:
150
+ return JSONResponse(
151
+ status_code=404,
152
+ content={
153
+ "error": "Entry not found",
154
+ "sequence_number": sequence_number
155
+ }
156
+ )
157
+
158
+ # Get event data
159
+ event = entry.get_event()
160
+
161
+ return JSONResponse(
162
+ status_code=200,
163
+ content={
164
+ "entry_id": str(entry.id),
165
+ "sequence_number": entry.sequence_number,
166
+ "entry_hash": entry.entry_hash,
167
+ "previous_hash": entry.previous_hash,
168
+ "block_id": str(entry.block_id) if entry.block_id else None,
169
+ "created_at": entry.created_at.isoformat(),
170
+ "is_verified": entry.is_verified,
171
+ "event": {
172
+ "event_type": event.event_type,
173
+ "actor_id": event.actor_id,
174
+ "resource_id": event.resource_id,
175
+ "action": event.action,
176
+ "metadata": event.metadata,
177
+ "timestamp": event.timestamp.isoformat(),
178
+ "data_hash": event.data_hash
179
+ }
180
+ }
181
+ )
182
+
183
+ except ValueError as e:
184
+ return JSONResponse(
185
+ status_code=400,
186
+ content={
187
+ "error": "Invalid sequence number",
188
+ "reason": str(e)
189
+ }
190
+ )
191
+ except Exception as e:
192
+ logger.error(f"Failed to get entry: {e}")
193
+ return JSONResponse(
194
+ status_code=500,
195
+ content={
196
+ "error": "Failed to get entry",
197
+ "reason": str(e)
198
+ }
199
+ )
200
+
201
+
202
+ async def get_block_endpoint(request: Request) -> JSONResponse:
203
+ """HTTP endpoint for getting a ledger block by block number.
204
+
205
+ Path parameter: block_number (int)
206
+ """
207
+ try:
208
+ # Get block number from path
209
+ block_number = int(request.path_params["block"])
210
+
211
+ # Get ledger from request state
212
+ ledger: ProvenanceLedger = request.app.state.ledger
213
+
214
+ # Get block
215
+ block = ledger.get_block(block_number)
216
+
217
+ if not block:
218
+ return JSONResponse(
219
+ status_code=404,
220
+ content={
221
+ "error": "Block not found",
222
+ "block_number": block_number
223
+ }
224
+ )
225
+
226
+ # Get entries in block
227
+ entries = ledger.get_block_entries(block_number)
228
+
229
+ return JSONResponse(
230
+ status_code=200,
231
+ content={
232
+ "block_id": str(block.id),
233
+ "block_number": block.block_number,
234
+ "entry_count": block.entry_count,
235
+ "first_entry_sequence": block.first_entry_sequence,
236
+ "last_entry_sequence": block.last_entry_sequence,
237
+ "merkle_root": block.merkle_root,
238
+ "created_at": block.created_at.isoformat(),
239
+ "sealed_at": block.sealed_at.isoformat() if block.sealed_at else None,
240
+ "is_verified": block.is_verified,
241
+ "verification_timestamp": block.verification_timestamp.isoformat() if block.verification_timestamp else None,
242
+ "entries": [
243
+ {
244
+ "entry_id": str(entry.id),
245
+ "sequence_number": entry.sequence_number,
246
+ "entry_hash": entry.entry_hash,
247
+ "created_at": entry.created_at.isoformat()
248
+ }
249
+ for entry in entries
250
+ ]
251
+ }
252
+ )
253
+
254
+ except ValueError as e:
255
+ return JSONResponse(
256
+ status_code=400,
257
+ content={
258
+ "error": "Invalid block number",
259
+ "reason": str(e)
260
+ }
261
+ )
262
+ except Exception as e:
263
+ logger.error(f"Failed to get block: {e}")
264
+ return JSONResponse(
265
+ status_code=500,
266
+ content={
267
+ "error": "Failed to get block",
268
+ "reason": str(e)
269
+ }
270
+ )
271
+
272
+
273
+ async def verify_chain_endpoint(request: Request) -> JSONResponse:
274
+ """HTTP endpoint for verifying chain integrity.
275
+
276
+ Query parameters:
277
+ - start_sequence (int, optional): Starting sequence number (default: 1)
278
+ - end_sequence (int, optional): Ending sequence number (default: None)
279
+ """
280
+ try:
281
+ # Get query parameters
282
+ start_sequence = int(request.query_params.get("start_sequence", 1))
283
+ end_sequence = request.query_params.get("end_sequence")
284
+ if end_sequence:
285
+ end_sequence = int(end_sequence)
286
+
287
+ # Get ledger from request state
288
+ ledger: ProvenanceLedger = request.app.state.ledger
289
+
290
+ # Verify chain integrity
291
+ is_valid = ledger.verify_chain_integrity(start_sequence, end_sequence)
292
+
293
+ return JSONResponse(
294
+ status_code=200,
295
+ content={
296
+ "verified": is_valid,
297
+ "start_sequence": start_sequence,
298
+ "end_sequence": end_sequence,
299
+ "verification_timestamp": ledger._get_next_sequence_number() - 1
300
+ }
301
+ )
302
+
303
+ except ValueError as e:
304
+ return JSONResponse(
305
+ status_code=400,
306
+ content={
307
+ "error": "Invalid sequence parameters",
308
+ "reason": str(e)
309
+ }
310
+ )
311
+ except Exception as e:
312
+ logger.error(f"Failed to verify chain: {e}")
313
+ return JSONResponse(
314
+ status_code=500,
315
+ content={
316
+ "error": "Failed to verify chain",
317
+ "reason": str(e)
318
+ }
319
+ )
320
+
321
+
322
+ async def get_merkle_proof_endpoint(request: Request) -> JSONResponse:
323
+ """HTTP endpoint for getting a Merkle proof for an entry.
324
+
325
+ Path parameter: sequence_number (int)
326
+ """
327
+ try:
328
+ # Get sequence number from path
329
+ sequence_number = int(request.path_params["sequence"])
330
+
331
+ # Get ledger from request state
332
+ ledger: ProvenanceLedger = request.app.state.ledger
333
+
334
+ # Get entry
335
+ entry = ledger.get_entry(sequence_number)
336
+
337
+ if not entry:
338
+ return JSONResponse(
339
+ status_code=404,
340
+ content={
341
+ "error": "Entry not found",
342
+ "sequence_number": sequence_number
343
+ }
344
+ )
345
+
346
+ # Get block - we need to find the block number from the entry
347
+ block = None
348
+ if entry.block_id:
349
+ # Find the block by querying for the block that contains this entry
350
+ from sqlmodel import Session, select
351
+ with Session(ledger.engine) as session:
352
+ block_query = session.exec(
353
+ select(LedgerBlock).where(LedgerBlock.id == entry.block_id)
354
+ ).first()
355
+ if block_query:
356
+ block = ledger.get_block(block_query.block_number)
357
+
358
+ if not block:
359
+ return JSONResponse(
360
+ status_code=404,
361
+ content={
362
+ "error": "Block not found for entry",
363
+ "sequence_number": sequence_number
364
+ }
365
+ )
366
+
367
+ # Get all entries in block
368
+ entries = ledger.get_block_entries(block.block_number)
369
+
370
+ # Create Merkle tree and generate proof
371
+ from fastmcp.ledger.merkle import MerkleTree
372
+ entry_hashes = [e.entry_hash for e in entries]
373
+ merkle_tree = MerkleTree(entry_hashes)
374
+
375
+ proof = merkle_tree.generate_proof(entry.entry_hash)
376
+
377
+ if not proof:
378
+ return JSONResponse(
379
+ status_code=500,
380
+ content={
381
+ "error": "Failed to generate Merkle proof",
382
+ "sequence_number": sequence_number
383
+ }
384
+ )
385
+
386
+ return JSONResponse(
387
+ status_code=200,
388
+ content={
389
+ "sequence_number": sequence_number,
390
+ "entry_hash": entry.entry_hash,
391
+ "block_number": block.block_number,
392
+ "merkle_root": block.merkle_root,
393
+ "proof": {
394
+ "leaf_hash": proof.leaf_hash,
395
+ "path": proof.path,
396
+ "root_hash": proof.root_hash
397
+ },
398
+ "verified": proof.verify()
399
+ }
400
+ )
401
+
402
+ except ValueError as e:
403
+ return JSONResponse(
404
+ status_code=400,
405
+ content={
406
+ "error": "Invalid sequence number",
407
+ "reason": str(e)
408
+ }
409
+ )
410
+ except Exception as e:
411
+ logger.error(f"Failed to get Merkle proof: {e}")
412
+ return JSONResponse(
413
+ status_code=500,
414
+ content={
415
+ "error": "Failed to get Merkle proof",
416
+ "reason": str(e)
417
+ }
418
+ )
419
+
420
+
421
+ async def get_ledger_statistics_endpoint(request: Request) -> JSONResponse:
422
+ """HTTP endpoint for getting ledger statistics."""
423
+ try:
424
+ # Get ledger from request state
425
+ ledger: ProvenanceLedger = request.app.state.ledger
426
+
427
+ # Get statistics
428
+ stats = ledger.get_ledger_statistics()
429
+
430
+ return JSONResponse(
431
+ status_code=200,
432
+ content=stats
433
+ )
434
+
435
+ except Exception as e:
436
+ logger.error(f"Failed to get ledger statistics: {e}")
437
+ return JSONResponse(
438
+ status_code=500,
439
+ content={
440
+ "error": "Failed to get ledger statistics",
441
+ "reason": str(e)
442
+ }
443
+ )
444
+
445
+
446
+ def create_ledger_routes(ledger: ProvenanceLedger) -> List[Route]:
447
+ """Create ledger management routes.
448
+
449
+ Args:
450
+ ledger: The provenance ledger instance
451
+
452
+ Returns:
453
+ List of Starlette Route objects for ledger management
454
+ """
455
+ def endpoint_with_ledger(endpoint_func):
456
+ async def wrapper(request: Request) -> JSONResponse:
457
+ # Store ledger in app state for access in endpoint
458
+ request.app.state.ledger = ledger
459
+ return await endpoint_func(request)
460
+ return wrapper
461
+
462
+ return [
463
+ Route(
464
+ path="/ledger/events",
465
+ endpoint=endpoint_with_ledger(append_event_endpoint),
466
+ methods=["POST"]
467
+ ),
468
+ Route(
469
+ path="/ledger/verify/{block}",
470
+ endpoint=endpoint_with_ledger(verify_block_endpoint),
471
+ methods=["GET"]
472
+ ),
473
+ Route(
474
+ path="/ledger/entries/{sequence}",
475
+ endpoint=endpoint_with_ledger(get_entry_endpoint),
476
+ methods=["GET"]
477
+ ),
478
+ Route(
479
+ path="/ledger/blocks/{block}",
480
+ endpoint=endpoint_with_ledger(get_block_endpoint),
481
+ methods=["GET"]
482
+ ),
483
+ Route(
484
+ path="/ledger/verify-chain",
485
+ endpoint=endpoint_with_ledger(verify_chain_endpoint),
486
+ methods=["GET"]
487
+ ),
488
+ Route(
489
+ path="/ledger/proof/{sequence}",
490
+ endpoint=endpoint_with_ledger(get_merkle_proof_endpoint),
491
+ methods=["GET"]
492
+ ),
493
+ Route(
494
+ path="/ledger/statistics",
495
+ endpoint=endpoint_with_ledger(get_ledger_statistics_endpoint),
496
+ methods=["GET"]
497
+ )
498
+ ]
src/fastmcp/server/server.py CHANGED
@@ -64,6 +64,7 @@ from fastmcp.tools import ToolManager
64
  from fastmcp.tools.tool import FunctionTool, Tool, ToolResult
65
  from fastmcp.tools.tool_transform import ToolTransformConfig
66
  from fastmcp.policy import PolicyEngine
 
67
  from fastmcp.contracts import ContractEngine
68
  from fastmcp.utilities.cli import log_server_banner
69
  from fastmcp.utilities.components import FastMCPComponent
@@ -177,6 +178,7 @@ class FastMCP(Generic[LifespanResultT]):
177
  self._additional_http_routes: list[BaseRoute] = []
178
  self._mounted_servers: list[MountedServer] = []
179
  self._policy_engine: Optional[PolicyEngine] = None
 
180
  self._contract_engine: Optional[ContractEngine] = None
181
  self._tool_manager = ToolManager(
182
  duplicate_behavior=on_duplicate_tools,
@@ -516,6 +518,13 @@ class FastMCP(Generic[LifespanResultT]):
516
  policy_route = create_policy_evaluate_route(self._policy_engine)
517
  routes.append(policy_route)
518
 
 
 
 
 
 
 
 
519
  # Add contract management endpoints if contract engine is configured
520
  if self._contract_engine is not None:
521
  from fastmcp.server.contract_routes import create_contract_routes
@@ -554,6 +563,31 @@ class FastMCP(Generic[LifespanResultT]):
554
  """
555
  return self._policy_engine
556
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
557
  def enable_contract_engine(self, contract_engine: Optional[ContractEngine] = None, database_url: str = "sqlite:///contracts.db") -> ContractEngine:
558
  """Enable the contract engine for this server.
559
 
 
64
  from fastmcp.tools.tool import FunctionTool, Tool, ToolResult
65
  from fastmcp.tools.tool_transform import ToolTransformConfig
66
  from fastmcp.policy import PolicyEngine
67
+ from fastmcp.ledger import ProvenanceLedger
68
  from fastmcp.contracts import ContractEngine
69
  from fastmcp.utilities.cli import log_server_banner
70
  from fastmcp.utilities.components import FastMCPComponent
 
178
  self._additional_http_routes: list[BaseRoute] = []
179
  self._mounted_servers: list[MountedServer] = []
180
  self._policy_engine: Optional[PolicyEngine] = None
181
+ self._ledger: Optional[ProvenanceLedger] = None
182
  self._contract_engine: Optional[ContractEngine] = None
183
  self._tool_manager = ToolManager(
184
  duplicate_behavior=on_duplicate_tools,
 
518
  policy_route = create_policy_evaluate_route(self._policy_engine)
519
  routes.append(policy_route)
520
 
521
+ # Add ledger management endpoints if ledger is configured
522
+ if self._ledger is not None:
523
+ from fastmcp.server.ledger_routes import create_ledger_routes
524
+
525
+ ledger_routes = create_ledger_routes(self._ledger)
526
+ routes.extend(ledger_routes)
527
+
528
  # Add contract management endpoints if contract engine is configured
529
  if self._contract_engine is not None:
530
  from fastmcp.server.contract_routes import create_contract_routes
 
563
  """
564
  return self._policy_engine
565
 
566
+ def enable_ledger(self, ledger: Optional[ProvenanceLedger] = None, database_url: str = "sqlite:///ledger.db") -> ProvenanceLedger:
567
+ """Enable the provenance ledger for this server.
568
+
569
+ Args:
570
+ ledger: Optional ledger instance. If None, creates a new one.
571
+ database_url: Database URL for ledger persistence
572
+
573
+ Returns:
574
+ The ledger instance
575
+ """
576
+ if ledger is None:
577
+ ledger = ProvenanceLedger(database_url)
578
+
579
+ self._ledger = ledger
580
+ logger.info("Provenance ledger enabled for server")
581
+ return ledger
582
+
583
+ def get_ledger(self) -> Optional[ProvenanceLedger]:
584
+ """Get the provenance ledger instance.
585
+
586
+ Returns:
587
+ The ledger instance, or None if not enabled
588
+ """
589
+ return self._ledger
590
+
591
  def enable_contract_engine(self, contract_engine: Optional[ContractEngine] = None, database_url: str = "sqlite:///contracts.db") -> ContractEngine:
592
  """Enable the contract engine for this server.
593
 
tests/ledger/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Tests for the FastMCP Ledger module."""
tests/ledger/test_ledger_adapters.py ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the ledger adapters."""
2
+
3
+ import pytest
4
+ from fastmcp.ledger.adapter import HyperledgerAdapter, OmniSealAdapter, StubAdapter
5
+
6
+
7
+ class TestHyperledgerAdapter:
8
+ """Test the HyperledgerAdapter class."""
9
+
10
+ @pytest.fixture
11
+ def adapter(self):
12
+ """Create a Hyperledger adapter for testing."""
13
+ return HyperledgerAdapter(
14
+ network_config="test_config.json",
15
+ channel_name="test-channel",
16
+ chaincode_name="test-chaincode"
17
+ )
18
+
19
+ async def test_adapter_initialization(self, adapter):
20
+ """Test adapter initialization."""
21
+ assert adapter.network_config == "test_config.json"
22
+ assert adapter.channel_name == "test-channel"
23
+ assert adapter.chaincode_name == "test-chaincode"
24
+ assert adapter.peer_endpoint == "localhost:7051"
25
+ assert adapter.orderer_endpoint == "localhost:7050"
26
+
27
+ async def test_submit_block(self, adapter):
28
+ """Test block submission."""
29
+ block_data = {
30
+ "block_number": 1,
31
+ "merkle_root": "test_merkle_root",
32
+ "entry_count": 5,
33
+ "entries": ["entry1", "entry2", "entry3", "entry4", "entry5"]
34
+ }
35
+
36
+ tx_id = await adapter.submit_block(block_data)
37
+
38
+ assert tx_id is not None
39
+ assert len(tx_id) == 64 # SHA-256 hex length
40
+
41
+ async def test_verify_block(self, adapter):
42
+ """Test block verification."""
43
+ block_id = "test_block_1"
44
+
45
+ result = await adapter.verify_block(block_id)
46
+
47
+ # Stub implementation always returns True
48
+ assert result is True
49
+
50
+ async def test_get_block_proof(self, adapter):
51
+ """Test getting block proof."""
52
+ block_id = "test_block_1"
53
+
54
+ proof = await adapter.get_block_proof(block_id)
55
+
56
+ assert proof is not None
57
+ assert proof["block_id"] == block_id
58
+ assert "block_hash" in proof
59
+ assert "block_number" in proof
60
+ assert "timestamp" in proof
61
+ assert "proof_type" in proof
62
+ assert proof["proof_type"] == "hyperledger_fabric"
63
+ assert "signatures" in proof
64
+ assert "merkle_root" in proof
65
+
66
+
67
+ class TestOmniSealAdapter:
68
+ """Test the OmniSealAdapter class."""
69
+
70
+ @pytest.fixture
71
+ def adapter(self):
72
+ """Create an OmniSeal adapter for testing."""
73
+ return OmniSealAdapter(
74
+ api_endpoint="https://api.test.omniseal.com",
75
+ api_key="test_api_key",
76
+ network_id="testnet"
77
+ )
78
+
79
+ async def test_adapter_initialization(self, adapter):
80
+ """Test adapter initialization."""
81
+ assert adapter.api_endpoint == "https://api.test.omniseal.com"
82
+ assert adapter.api_key == "test_api_key"
83
+ assert adapter.network_id == "testnet"
84
+
85
+ async def test_submit_block(self, adapter):
86
+ """Test block submission."""
87
+ block_data = {
88
+ "block_number": 1,
89
+ "merkle_root": "test_merkle_root",
90
+ "entry_count": 3,
91
+ "entries": ["entry1", "entry2", "entry3"]
92
+ }
93
+
94
+ tx_id = await adapter.submit_block(block_data)
95
+
96
+ assert tx_id is not None
97
+ assert len(tx_id) == 64 # SHA-256 hex length
98
+
99
+ async def test_verify_block(self, adapter):
100
+ """Test block verification."""
101
+ block_id = "test_block_1"
102
+
103
+ result = await adapter.verify_block(block_id)
104
+
105
+ # Stub implementation always returns True
106
+ assert result is True
107
+
108
+ async def test_get_block_proof(self, adapter):
109
+ """Test getting block proof."""
110
+ block_id = "test_block_1"
111
+
112
+ proof = await adapter.get_block_proof(block_id)
113
+
114
+ assert proof is not None
115
+ assert proof["block_id"] == block_id
116
+ assert "block_hash" in proof
117
+ assert "block_number" in proof
118
+ assert "timestamp" in proof
119
+ assert "proof_type" in proof
120
+ assert proof["proof_type"] == "omniseal"
121
+ assert "network_id" in proof
122
+ assert proof["network_id"] == "testnet"
123
+ assert "merkle_root" in proof
124
+
125
+
126
+ class TestStubAdapter:
127
+ """Test the StubAdapter class."""
128
+
129
+ @pytest.fixture
130
+ def adapter(self):
131
+ """Create a stub adapter for testing."""
132
+ return StubAdapter()
133
+
134
+ async def test_adapter_initialization(self, adapter):
135
+ """Test adapter initialization."""
136
+ assert adapter.submitted_blocks == {}
137
+ assert adapter.block_proofs == {}
138
+
139
+ async def test_submit_block(self, adapter):
140
+ """Test block submission."""
141
+ block_data = {
142
+ "block_number": 1,
143
+ "merkle_root": "test_merkle_root",
144
+ "entry_count": 2,
145
+ "entries": ["entry1", "entry2"]
146
+ }
147
+
148
+ block_id = await adapter.submit_block(block_data)
149
+
150
+ assert block_id == "stub_block_1"
151
+ assert block_id in adapter.submitted_blocks
152
+ assert adapter.submitted_blocks[block_id] == block_data
153
+
154
+ async def test_submit_multiple_blocks(self, adapter):
155
+ """Test submitting multiple blocks."""
156
+ for i in range(3):
157
+ block_data = {
158
+ "block_number": i + 1,
159
+ "merkle_root": f"merkle_root_{i}",
160
+ "entry_count": 1,
161
+ "entries": [f"entry_{i}"]
162
+ }
163
+
164
+ block_id = await adapter.submit_block(block_data)
165
+ assert block_id == f"stub_block_{i + 1}"
166
+
167
+ assert len(adapter.submitted_blocks) == 3
168
+
169
+ async def test_verify_block(self, adapter):
170
+ """Test block verification."""
171
+ # Submit a block first
172
+ block_data = {"block_number": 1, "merkle_root": "test"}
173
+ block_id = await adapter.submit_block(block_data)
174
+
175
+ # Verify existing block
176
+ result = await adapter.verify_block(block_id)
177
+ assert result is True
178
+
179
+ # Verify non-existent block
180
+ result = await adapter.verify_block("nonexistent_block")
181
+ assert result is False
182
+
183
+ async def test_get_block_proof(self, adapter):
184
+ """Test getting block proof."""
185
+ # Submit a block first
186
+ block_data = {
187
+ "block_number": 1,
188
+ "merkle_root": "test_merkle_root",
189
+ "entry_count": 2
190
+ }
191
+ block_id = await adapter.submit_block(block_data)
192
+
193
+ # Get proof for existing block
194
+ proof = await adapter.get_block_proof(block_id)
195
+
196
+ assert proof is not None
197
+ assert proof["block_id"] == block_id
198
+ assert proof["block_hash"] == f"stub_hash_{block_id}"
199
+ assert proof["block_number"] == 1
200
+ assert "timestamp" in proof
201
+ assert proof["proof_type"] == "stub"
202
+ assert proof["merkle_root"] == "test_merkle_root"
203
+
204
+ # Get proof for non-existent block
205
+ proof = await adapter.get_block_proof("nonexistent_block")
206
+ assert proof is None
207
+
208
+ async def test_adapter_state_persistence(self, adapter):
209
+ """Test that adapter maintains state across operations."""
210
+ # Submit a block
211
+ block_data = {"block_number": 1, "merkle_root": "test"}
212
+ block_id = await adapter.submit_block(block_data)
213
+
214
+ # Verify it exists
215
+ assert await adapter.verify_block(block_id) is True
216
+
217
+ # Get proof
218
+ proof = await adapter.get_block_proof(block_id)
219
+ assert proof is not None
220
+
221
+ # Submit another block
222
+ block_data2 = {"block_number": 2, "merkle_root": "test2"}
223
+ block_id2 = await adapter.submit_block(block_data2)
224
+
225
+ # Both blocks should exist
226
+ assert await adapter.verify_block(block_id) is True
227
+ assert await adapter.verify_block(block_id2) is True
228
+
229
+ # Both proofs should be available
230
+ proof1 = await adapter.get_block_proof(block_id)
231
+ proof2 = await adapter.get_block_proof(block_id2)
232
+ assert proof1 is not None
233
+ assert proof2 is not None
234
+ assert proof1["block_id"] != proof2["block_id"]
tests/ledger/test_ledger_core.py ADDED
@@ -0,0 +1,305 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the core ledger functionality."""
2
+
3
+ import pytest
4
+ from datetime import datetime
5
+ from uuid import uuid4
6
+
7
+ from fastmcp.ledger import ProvenanceLedger, LedgerEvent, EventType, LedgerEntry, LedgerBlock
8
+
9
+
10
+ class TestProvenanceLedger:
11
+ """Test the ProvenanceLedger class."""
12
+
13
+ @pytest.fixture
14
+ def ledger(self):
15
+ """Create a ledger instance for testing."""
16
+ return ProvenanceLedger("sqlite:///:memory:")
17
+
18
+ @pytest.fixture
19
+ def sample_event(self):
20
+ """Create a sample event for testing."""
21
+ return LedgerEvent(
22
+ event_type=EventType.TOOL_CALL,
23
+ actor_id="user123",
24
+ resource_id="resource456",
25
+ action="execute_tool",
26
+ metadata={"tool_name": "test_tool", "parameters": {"x": 1, "y": 2}},
27
+ data_hash="sha256_hash_of_data"
28
+ )
29
+
30
+ def test_ledger_initialization(self, ledger):
31
+ """Test ledger initialization."""
32
+ assert ledger is not None
33
+ assert ledger._current_sequence == 1
34
+ assert ledger._current_block is None
35
+ assert ledger._block_size == 100
36
+
37
+ def test_append_event(self, ledger, sample_event):
38
+ """Test appending an event to the ledger."""
39
+ entry = ledger.append_event(sample_event)
40
+
41
+ assert entry is not None
42
+ assert entry.sequence_number == 1
43
+ assert entry.previous_hash is None
44
+ assert entry.entry_hash is not None
45
+ assert entry.block_id is not None
46
+ assert entry.is_verified is True
47
+
48
+ # Verify the event data
49
+ event = entry.get_event()
50
+ assert event.event_type == sample_event.event_type
51
+ assert event.actor_id == sample_event.actor_id
52
+ assert event.action == sample_event.action
53
+
54
+ def test_hash_chaining(self, ledger, sample_event):
55
+ """Test that entries are properly hash-chained."""
56
+ # Append first event
57
+ entry1 = ledger.append_event(sample_event)
58
+
59
+ # Append second event
60
+ entry2 = ledger.append_event(sample_event)
61
+
62
+ # Verify hash chaining
63
+ assert entry2.previous_hash == entry1.entry_hash
64
+ assert entry2.sequence_number == entry1.sequence_number + 1
65
+
66
+ def test_entry_integrity_verification(self, ledger, sample_event):
67
+ """Test entry integrity verification."""
68
+ entry = ledger.append_event(sample_event)
69
+
70
+ # Verify entry integrity
71
+ assert entry.verify_integrity() is True
72
+
73
+ # Tamper with the entry
74
+ original_hash = entry.entry_hash
75
+ entry.entry_hash = "tampered_hash"
76
+
77
+ # Verify integrity fails
78
+ assert entry.verify_integrity() is False
79
+
80
+ # Restore original hash
81
+ entry.entry_hash = original_hash
82
+ assert entry.verify_integrity() is True
83
+
84
+ def test_get_entry(self, ledger, sample_event):
85
+ """Test retrieving an entry by sequence number."""
86
+ entry = ledger.append_event(sample_event)
87
+
88
+ retrieved_entry = ledger.get_entry(entry.sequence_number)
89
+ assert retrieved_entry is not None
90
+ assert retrieved_entry.id == entry.id
91
+ assert retrieved_entry.sequence_number == entry.sequence_number
92
+
93
+ def test_get_nonexistent_entry(self, ledger):
94
+ """Test retrieving a non-existent entry."""
95
+ entry = ledger.get_entry(999)
96
+ assert entry is None
97
+
98
+ def test_block_creation_and_sealing(self, ledger, sample_event):
99
+ """Test block creation and sealing."""
100
+ # Append events to fill a block
101
+ entries = []
102
+ for i in range(5): # Use a smaller number for testing
103
+ event = LedgerEvent(
104
+ event_type=EventType.TOOL_CALL,
105
+ actor_id=f"user{i}",
106
+ action=f"action_{i}",
107
+ metadata={"index": i}
108
+ )
109
+ entry = ledger.append_event(event)
110
+ entries.append(entry)
111
+
112
+ # Manually seal the block (normally done when block_size is reached)
113
+ from sqlmodel import Session
114
+ with Session(ledger.engine) as session:
115
+ ledger._seal_block(session)
116
+
117
+ # Get the block
118
+ block = ledger.get_block(1)
119
+ assert block is not None
120
+ assert block.block_number == 1
121
+ assert block.entry_count == 5
122
+ assert block.merkle_root is not None
123
+ assert block.sealed_at is not None
124
+ assert block.is_verified is True
125
+
126
+ def test_block_integrity_verification(self, ledger, sample_event):
127
+ """Test block integrity verification."""
128
+ # Create a block with multiple entries
129
+ entries = []
130
+ for i in range(3):
131
+ event = LedgerEvent(
132
+ event_type=EventType.TOOL_CALL,
133
+ actor_id=f"user{i}",
134
+ action=f"action_{i}"
135
+ )
136
+ entry = ledger.append_event(event)
137
+ entries.append(entry)
138
+
139
+ # Seal the block
140
+ from sqlmodel import Session
141
+ with Session(ledger.engine) as session:
142
+ ledger._seal_block(session)
143
+
144
+ # Verify block integrity
145
+ assert ledger.verify_block_integrity(1) is True
146
+
147
+ # Tamper with an entry
148
+ entry = ledger.get_entry(1)
149
+ original_hash = entry.entry_hash
150
+ entry.entry_hash = "tampered_hash"
151
+
152
+ # Update the entry in the database
153
+ with Session(ledger.engine) as session:
154
+ session.add(entry)
155
+ session.commit()
156
+
157
+ # Verify block integrity fails
158
+ assert ledger.verify_block_integrity(1) is False
159
+
160
+ def test_chain_integrity_verification(self, ledger, sample_event):
161
+ """Test chain integrity verification."""
162
+ # Append multiple events
163
+ for i in range(5):
164
+ event = LedgerEvent(
165
+ event_type=EventType.TOOL_CALL,
166
+ actor_id=f"user{i}",
167
+ action=f"action_{i}"
168
+ )
169
+ ledger.append_event(event)
170
+
171
+ # Verify entire chain
172
+ assert ledger.verify_chain_integrity() is True
173
+
174
+ # Verify partial chain
175
+ assert ledger.verify_chain_integrity(start_sequence=2, end_sequence=4) is True
176
+
177
+ def test_chain_integrity_with_tampering(self, ledger, sample_event):
178
+ """Test chain integrity verification with tampered entries."""
179
+ # Append multiple events
180
+ entries = []
181
+ for i in range(3):
182
+ event = LedgerEvent(
183
+ event_type=EventType.TOOL_CALL,
184
+ actor_id=f"user{i}",
185
+ action=f"action_{i}"
186
+ )
187
+ entry = ledger.append_event(event)
188
+ entries.append(entry)
189
+
190
+ # Verify chain is intact
191
+ assert ledger.verify_chain_integrity() is True
192
+
193
+ # Tamper with middle entry
194
+ middle_entry = entries[1]
195
+ original_hash = middle_entry.entry_hash
196
+ middle_entry.entry_hash = "tampered_hash"
197
+
198
+ # Update in database
199
+ from sqlmodel import Session
200
+ with Session(ledger.engine) as session:
201
+ session.add(middle_entry)
202
+ session.commit()
203
+
204
+ # Verify chain integrity fails
205
+ assert ledger.verify_chain_integrity() is False
206
+
207
+ def test_ledger_statistics(self, ledger, sample_event):
208
+ """Test ledger statistics."""
209
+ # Initially empty
210
+ stats = ledger.get_ledger_statistics()
211
+ assert stats["total_entries"] == 0
212
+ assert stats["total_blocks"] == 0
213
+ assert stats["current_sequence"] == 0
214
+
215
+ # Add some entries
216
+ for i in range(3):
217
+ event = LedgerEvent(
218
+ event_type=EventType.TOOL_CALL,
219
+ actor_id=f"user{i}",
220
+ action=f"action_{i}"
221
+ )
222
+ ledger.append_event(event)
223
+
224
+ # Check updated statistics
225
+ stats = ledger.get_ledger_statistics()
226
+ assert stats["total_entries"] == 3
227
+ assert stats["total_blocks"] == 1
228
+ assert stats["current_sequence"] == 3
229
+
230
+ def test_different_event_types(self, ledger):
231
+ """Test appending different types of events."""
232
+ event_types = [
233
+ EventType.TOOL_CALL,
234
+ EventType.POLICY_DECISION,
235
+ EventType.DATA_FLOW,
236
+ EventType.CONTRACT_ACTION,
237
+ EventType.AUTHENTICATION,
238
+ EventType.AUTHORIZATION,
239
+ EventType.SYSTEM_EVENT
240
+ ]
241
+
242
+ for event_type in event_types:
243
+ event = LedgerEvent(
244
+ event_type=event_type,
245
+ actor_id="test_actor",
246
+ action="test_action"
247
+ )
248
+ entry = ledger.append_event(event)
249
+
250
+ retrieved_event = entry.get_event()
251
+ assert retrieved_event.event_type == event_type
252
+
253
+ def test_event_with_metadata(self, ledger):
254
+ """Test events with complex metadata."""
255
+ metadata = {
256
+ "nested": {"key": "value"},
257
+ "list": [1, 2, 3],
258
+ "boolean": True,
259
+ "null": None
260
+ }
261
+
262
+ event = LedgerEvent(
263
+ event_type=EventType.TOOL_CALL,
264
+ actor_id="test_actor",
265
+ action="test_action",
266
+ metadata=metadata
267
+ )
268
+
269
+ entry = ledger.append_event(event)
270
+ retrieved_event = entry.get_event()
271
+
272
+ assert retrieved_event.metadata == metadata
273
+
274
+ def test_event_content_hash(self, ledger):
275
+ """Test event content hash generation."""
276
+ event = LedgerEvent(
277
+ event_type=EventType.TOOL_CALL,
278
+ actor_id="test_actor",
279
+ action="test_action",
280
+ metadata={"key": "value"}
281
+ )
282
+
283
+ content_hash = event.get_content_hash()
284
+ assert content_hash is not None
285
+ assert len(content_hash) == 64 # SHA-256 hex length
286
+
287
+ # Same event should produce same hash
288
+ event2 = LedgerEvent(
289
+ event_type=EventType.TOOL_CALL,
290
+ actor_id="test_actor",
291
+ action="test_action",
292
+ metadata={"key": "value"}
293
+ )
294
+
295
+ assert event2.get_content_hash() == content_hash
296
+
297
+ # Different event should produce different hash
298
+ event3 = LedgerEvent(
299
+ event_type=EventType.TOOL_CALL,
300
+ actor_id="test_actor",
301
+ action="different_action",
302
+ metadata={"key": "value"}
303
+ )
304
+
305
+ assert event3.get_content_hash() != content_hash
tests/ledger/test_ledger_http.py ADDED
@@ -0,0 +1,319 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the ledger HTTP endpoints."""
2
+
3
+ import pytest
4
+ import httpx
5
+ from fastmcp import FastMCP
6
+ from fastmcp.ledger import ProvenanceLedger, LedgerEvent, EventType
7
+
8
+
9
+ @pytest.fixture
10
+ def server_with_ledger():
11
+ """Create a FastMCP server with ledger enabled."""
12
+ server = FastMCP("TestLedgerServer")
13
+ ledger = server.enable_ledger(database_url="sqlite:///:memory:")
14
+ return server, ledger
15
+
16
+
17
+ @pytest.fixture
18
+ async def client(server_with_ledger):
19
+ """Create an HTTP client for testing."""
20
+ server, ledger = server_with_ledger
21
+ from fastmcp.server.http import create_streamable_http_app
22
+ app = create_streamable_http_app(server, streamable_http_path="/")
23
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
24
+ # Store server and ledger in client for test access
25
+ client.server = server
26
+ client.ledger = ledger
27
+ yield client
28
+
29
+
30
+ class TestLedgerHTTPEndpoints:
31
+ """Test the ledger HTTP endpoints."""
32
+
33
+ async def test_append_event_endpoint(self, client):
34
+ """Test the append event endpoint."""
35
+ event_data = {
36
+ "event_type": "tool_call",
37
+ "actor_id": "user123",
38
+ "resource_id": "resource456",
39
+ "action": "execute_tool",
40
+ "metadata": {"tool_name": "test_tool"},
41
+ "data_hash": "sha256_hash"
42
+ }
43
+
44
+ response = await client.post("/ledger/events", json=event_data)
45
+
46
+ assert response.status_code == 201
47
+ data = response.json()
48
+ assert "entry_id" in data
49
+ assert "sequence_number" in data
50
+ assert "entry_hash" in data
51
+ assert "block_id" in data
52
+ assert "created_at" in data
53
+ assert data["sequence_number"] == 1
54
+
55
+ async def test_append_event_invalid_data(self, client):
56
+ """Test append event with invalid data."""
57
+ invalid_data = {
58
+ "event_type": "invalid_type",
59
+ "actor_id": "user123",
60
+ "action": "test_action"
61
+ }
62
+
63
+ response = await client.post("/ledger/events", json=invalid_data)
64
+
65
+ assert response.status_code == 400
66
+ data = response.json()
67
+ assert "error" in data
68
+ assert "Invalid event data" in data["error"]
69
+
70
+ async def test_verify_block_endpoint(self, client):
71
+ """Test the verify block endpoint."""
72
+ # First, add some events to create a block
73
+ for i in range(3):
74
+ event_data = {
75
+ "event_type": "tool_call",
76
+ "actor_id": f"user{i}",
77
+ "action": f"action_{i}"
78
+ }
79
+ await client.post("/ledger/events", json=event_data)
80
+
81
+ # Manually seal the block for testing
82
+ client.ledger.seal_current_block()
83
+
84
+ # Verify the block
85
+ response = await client.get("/ledger/verify/1")
86
+
87
+ assert response.status_code == 200
88
+ data = response.json()
89
+ assert data["block_number"] == 1
90
+ assert data["verified"] is True
91
+ assert "block_id" in data
92
+ assert "entry_count" in data
93
+ assert "merkle_root" in data
94
+
95
+ async def test_verify_nonexistent_block(self, client):
96
+ """Test verifying a non-existent block."""
97
+ response = await client.get("/ledger/verify/999")
98
+
99
+ assert response.status_code == 400
100
+ data = response.json()
101
+ assert "error" in data
102
+ assert "Block integrity verification failed" in data["error"]
103
+
104
+ async def test_get_entry_endpoint(self, client):
105
+ """Test the get entry endpoint."""
106
+ # First, add an event
107
+ event_data = {
108
+ "event_type": "tool_call",
109
+ "actor_id": "user123",
110
+ "action": "test_action"
111
+ }
112
+ await client.post("/ledger/events", json=event_data)
113
+
114
+ # Get the entry
115
+ response = await client.get("/ledger/entries/1")
116
+
117
+ assert response.status_code == 200
118
+ data = response.json()
119
+ assert data["sequence_number"] == 1
120
+ assert "entry_hash" in data
121
+ assert "previous_hash" in data
122
+ assert "event" in data
123
+ assert data["event"]["actor_id"] == "user123"
124
+ assert data["event"]["action"] == "test_action"
125
+
126
+ async def test_get_nonexistent_entry(self, client):
127
+ """Test getting a non-existent entry."""
128
+ response = await client.get("/ledger/entries/999")
129
+
130
+ assert response.status_code == 404
131
+ data = response.json()
132
+ assert "error" in data
133
+ assert "Entry not found" in data["error"]
134
+
135
+ async def test_get_block_endpoint(self, client):
136
+ """Test the get block endpoint."""
137
+ # First, add some events to create a block
138
+ for i in range(3):
139
+ event_data = {
140
+ "event_type": "tool_call",
141
+ "actor_id": f"user{i}",
142
+ "action": f"action_{i}"
143
+ }
144
+ await client.post("/ledger/events", json=event_data)
145
+
146
+ # Get the block
147
+ response = await client.get("/ledger/blocks/1")
148
+
149
+ assert response.status_code == 200
150
+ data = response.json()
151
+ assert data["block_number"] == 1
152
+ assert "entry_count" in data
153
+ assert "merkle_root" in data
154
+ assert "entries" in data
155
+ assert len(data["entries"]) == 3
156
+
157
+ async def test_get_nonexistent_block(self, client):
158
+ """Test getting a non-existent block."""
159
+ response = await client.get("/ledger/blocks/999")
160
+
161
+ assert response.status_code == 404
162
+ data = response.json()
163
+ assert "error" in data
164
+ assert "Block not found" in data["error"]
165
+
166
+ async def test_verify_chain_endpoint(self, client):
167
+ """Test the verify chain endpoint."""
168
+ # Add some events
169
+ for i in range(3):
170
+ event_data = {
171
+ "event_type": "tool_call",
172
+ "actor_id": f"user{i}",
173
+ "action": f"action_{i}"
174
+ }
175
+ await client.post("/ledger/events", json=event_data)
176
+
177
+ # Verify the chain
178
+ response = await client.get("/ledger/verify-chain")
179
+
180
+ assert response.status_code == 200
181
+ data = response.json()
182
+ assert data["verified"] is True
183
+ assert "start_sequence" in data
184
+ assert "end_sequence" in data
185
+
186
+ async def test_verify_chain_with_range(self, client):
187
+ """Test verify chain with specific range."""
188
+ # Add some events
189
+ for i in range(5):
190
+ event_data = {
191
+ "event_type": "tool_call",
192
+ "actor_id": f"user{i}",
193
+ "action": f"action_{i}"
194
+ }
195
+ await client.post("/ledger/events", json=event_data)
196
+
197
+ # Verify partial chain
198
+ response = await client.get("/ledger/verify-chain?start_sequence=2&end_sequence=4")
199
+
200
+ assert response.status_code == 200
201
+ data = response.json()
202
+ assert data["verified"] is True
203
+ assert data["start_sequence"] == 2
204
+ assert data["end_sequence"] == 4
205
+
206
+ async def test_get_merkle_proof_endpoint(self, client):
207
+ """Test the get Merkle proof endpoint."""
208
+ # Add some events to create a block
209
+ for i in range(3):
210
+ event_data = {
211
+ "event_type": "tool_call",
212
+ "actor_id": f"user{i}",
213
+ "action": f"action_{i}"
214
+ }
215
+ await client.post("/ledger/events", json=event_data)
216
+
217
+ # Manually seal the block for testing
218
+ client.ledger.seal_current_block()
219
+
220
+ # Get Merkle proof for first entry
221
+ response = await client.get("/ledger/proof/1")
222
+
223
+ assert response.status_code == 200
224
+ data = response.json()
225
+ assert data["sequence_number"] == 1
226
+ assert "entry_hash" in data
227
+ assert "block_number" in data
228
+ assert "merkle_root" in data
229
+ assert "proof" in data
230
+ assert "leaf_hash" in data["proof"]
231
+ assert "path" in data["proof"]
232
+ assert "root_hash" in data["proof"]
233
+ assert data["verified"] is True
234
+
235
+ async def test_get_merkle_proof_nonexistent_entry(self, client):
236
+ """Test getting Merkle proof for non-existent entry."""
237
+ response = await client.get("/ledger/proof/999")
238
+
239
+ assert response.status_code == 404
240
+ data = response.json()
241
+ assert "error" in data
242
+ assert "Entry not found" in data["error"]
243
+
244
+ async def test_get_ledger_statistics_endpoint(self, client):
245
+ """Test the get ledger statistics endpoint."""
246
+ # Initially empty
247
+ response = await client.get("/ledger/statistics")
248
+
249
+ assert response.status_code == 200
250
+ data = response.json()
251
+ assert data["total_entries"] == 0
252
+ assert data["total_blocks"] == 0
253
+ assert data["current_sequence"] == 0
254
+
255
+ # Add some events
256
+ for i in range(3):
257
+ event_data = {
258
+ "event_type": "tool_call",
259
+ "actor_id": f"user{i}",
260
+ "action": f"action_{i}"
261
+ }
262
+ await client.post("/ledger/events", json=event_data)
263
+
264
+ # Check updated statistics
265
+ response = await client.get("/ledger/statistics")
266
+
267
+ assert response.status_code == 200
268
+ data = response.json()
269
+ assert data["total_entries"] == 3
270
+ assert data["total_blocks"] == 1
271
+ assert data["current_sequence"] == 3
272
+
273
+ async def test_multiple_event_types(self, client):
274
+ """Test appending different event types."""
275
+ event_types = [
276
+ "tool_call",
277
+ "policy_decision",
278
+ "data_flow",
279
+ "contract_action",
280
+ "authentication",
281
+ "authorization",
282
+ "system_event"
283
+ ]
284
+
285
+ for event_type in event_types:
286
+ event_data = {
287
+ "event_type": event_type,
288
+ "actor_id": "test_actor",
289
+ "action": "test_action"
290
+ }
291
+
292
+ response = await client.post("/ledger/events", json=event_data)
293
+ assert response.status_code == 201
294
+
295
+ async def test_event_with_complex_metadata(self, client):
296
+ """Test event with complex metadata."""
297
+ event_data = {
298
+ "event_type": "tool_call",
299
+ "actor_id": "test_actor",
300
+ "action": "test_action",
301
+ "metadata": {
302
+ "nested": {"key": "value"},
303
+ "list": [1, 2, 3],
304
+ "boolean": True,
305
+ "null": None
306
+ }
307
+ }
308
+
309
+ response = await client.post("/ledger/events", json=event_data)
310
+
311
+ assert response.status_code == 201
312
+ data = response.json()
313
+ assert data["sequence_number"] == 1
314
+
315
+ # Verify the event was stored correctly
316
+ entry_response = await client.get("/ledger/entries/1")
317
+ assert entry_response.status_code == 200
318
+ entry_data = entry_response.json()
319
+ assert entry_data["event"]["metadata"] == event_data["metadata"]
tests/ledger/test_merkle_tree.py ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the Merkle tree functionality."""
2
+
3
+ import pytest
4
+
5
+ from fastmcp.ledger.merkle import MerkleTree, MerkleProof, verify_merkle_proof
6
+
7
+
8
+ class TestMerkleTree:
9
+ """Test the MerkleTree class."""
10
+
11
+ def test_single_leaf(self):
12
+ """Test Merkle tree with a single leaf."""
13
+ leaf_hashes = ["hash1"]
14
+ tree = MerkleTree(leaf_hashes)
15
+
16
+ assert tree.get_root() == "hash1"
17
+ assert tree.get_leaf_count() == 1
18
+ assert tree.get_tree_height() == 1
19
+
20
+ def test_two_leaves(self):
21
+ """Test Merkle tree with two leaves."""
22
+ leaf_hashes = ["hash1", "hash2"]
23
+ tree = MerkleTree(leaf_hashes)
24
+
25
+ # Root should be hash of concatenated leaves
26
+ import hashlib
27
+ expected_root = hashlib.sha256(("hash1" + "hash2").encode()).hexdigest()
28
+ assert tree.get_root() == expected_root
29
+ assert tree.get_leaf_count() == 2
30
+ assert tree.get_tree_height() == 2
31
+
32
+ def test_three_leaves(self):
33
+ """Test Merkle tree with three leaves (odd number)."""
34
+ leaf_hashes = ["hash1", "hash2", "hash3"]
35
+ tree = MerkleTree(leaf_hashes)
36
+
37
+ # With odd number of leaves, last leaf should be duplicated
38
+ assert tree.get_leaf_count() == 3
39
+ assert tree.get_tree_height() == 3
40
+
41
+ def test_four_leaves(self):
42
+ """Test Merkle tree with four leaves."""
43
+ leaf_hashes = ["hash1", "hash2", "hash3", "hash4"]
44
+ tree = MerkleTree(leaf_hashes)
45
+
46
+ assert tree.get_leaf_count() == 4
47
+ assert tree.get_tree_height() == 3
48
+
49
+ def test_large_tree(self):
50
+ """Test Merkle tree with many leaves."""
51
+ leaf_hashes = [f"hash{i}" for i in range(100)]
52
+ tree = MerkleTree(leaf_hashes)
53
+
54
+ assert tree.get_leaf_count() == 100
55
+ assert tree.get_tree_height() > 1
56
+ assert tree.get_root() is not None
57
+
58
+ def test_generate_proof_single_leaf(self):
59
+ """Test proof generation for single leaf."""
60
+ leaf_hashes = ["hash1"]
61
+ tree = MerkleTree(leaf_hashes)
62
+
63
+ proof = tree.generate_proof("hash1")
64
+ assert proof is not None
65
+ assert proof.leaf_hash == "hash1"
66
+ assert proof.path == []
67
+ assert proof.root_hash == "hash1"
68
+ assert proof.verify() is True
69
+
70
+ def test_generate_proof_two_leaves(self):
71
+ """Test proof generation for two leaves."""
72
+ leaf_hashes = ["hash1", "hash2"]
73
+ tree = MerkleTree(leaf_hashes)
74
+
75
+ # Proof for first leaf
76
+ proof1 = tree.generate_proof("hash1")
77
+ assert proof1 is not None
78
+ assert proof1.leaf_hash == "hash1"
79
+ assert len(proof1.path) == 1
80
+ assert proof1.path[0]["hash"] == "hash2"
81
+ assert proof1.path[0]["position"] == "right"
82
+ assert proof1.verify() is True
83
+
84
+ # Proof for second leaf
85
+ proof2 = tree.generate_proof("hash2")
86
+ assert proof2 is not None
87
+ assert proof2.leaf_hash == "hash2"
88
+ assert len(proof2.path) == 1
89
+ assert proof2.path[0]["hash"] == "hash1"
90
+ assert proof2.path[0]["position"] == "left"
91
+ assert proof2.verify() is True
92
+
93
+ def test_generate_proof_nonexistent_leaf(self):
94
+ """Test proof generation for non-existent leaf."""
95
+ leaf_hashes = ["hash1", "hash2"]
96
+ tree = MerkleTree(leaf_hashes)
97
+
98
+ proof = tree.generate_proof("nonexistent")
99
+ assert proof is None
100
+
101
+ def test_verify_leaf(self):
102
+ """Test leaf verification."""
103
+ leaf_hashes = ["hash1", "hash2", "hash3"]
104
+ tree = MerkleTree(leaf_hashes)
105
+
106
+ # Verify existing leaves
107
+ assert tree.verify_leaf("hash1") is True
108
+ assert tree.verify_leaf("hash2") is True
109
+ assert tree.verify_leaf("hash3") is True
110
+
111
+ # Verify non-existent leaf
112
+ assert tree.verify_leaf("nonexistent") is False
113
+
114
+ def test_verify_proof(self):
115
+ """Test proof verification."""
116
+ leaf_hashes = ["hash1", "hash2", "hash3", "hash4"]
117
+ tree = MerkleTree(leaf_hashes)
118
+
119
+ proof = tree.generate_proof("hash1")
120
+ assert proof is not None
121
+ assert tree.verify_proof(proof) is True
122
+
123
+ # Tamper with proof
124
+ proof.leaf_hash = "tampered"
125
+ assert tree.verify_proof(proof) is False
126
+
127
+ def test_to_dict_and_from_dict(self):
128
+ """Test serialization and deserialization."""
129
+ leaf_hashes = ["hash1", "hash2", "hash3"]
130
+ tree1 = MerkleTree(leaf_hashes)
131
+
132
+ # Convert to dict
133
+ tree_dict = tree1.to_dict()
134
+ assert "root_hash" in tree_dict
135
+ assert "leaf_count" in tree_dict
136
+ assert "tree_height" in tree_dict
137
+ assert "tree_data" in tree_dict
138
+ assert "leaf_hashes" in tree_dict
139
+
140
+ # Convert back to tree
141
+ tree2 = MerkleTree.from_dict(tree_dict)
142
+ assert tree2.get_root() == tree1.get_root()
143
+ assert tree2.get_leaf_count() == tree1.get_leaf_count()
144
+ assert tree2.get_tree_height() == tree1.get_tree_height()
145
+ assert tree2.get_leaf_hashes() == tree1.get_leaf_hashes()
146
+
147
+ def test_empty_tree_raises_error(self):
148
+ """Test that empty tree raises error."""
149
+ with pytest.raises(ValueError, match="Cannot create Merkle tree with empty leaf list"):
150
+ MerkleTree([])
151
+
152
+
153
+ class TestMerkleProof:
154
+ """Test the MerkleProof class."""
155
+
156
+ def test_proof_verification_simple(self):
157
+ """Test simple proof verification."""
158
+ import hashlib
159
+
160
+ # Create a simple two-leaf tree
161
+ leaf_hash = "hash1"
162
+ sibling_hash = "hash2"
163
+ combined = sibling_hash + leaf_hash # sibling on left, leaf on right
164
+ root_hash = hashlib.sha256(combined.encode()).hexdigest()
165
+
166
+ proof = MerkleProof(
167
+ leaf_hash=leaf_hash,
168
+ path=[{"hash": sibling_hash, "position": "left"}],
169
+ root_hash=root_hash
170
+ )
171
+
172
+ assert proof.verify() is True
173
+
174
+ def test_proof_verification_complex(self):
175
+ """Test complex proof verification with multiple levels."""
176
+ import hashlib
177
+
178
+ # Create a more complex tree structure
179
+ leaf_hash = "hash1"
180
+ path = [
181
+ {"hash": "hash2", "position": "right"}, # sibling at leaf level
182
+ {"hash": "intermediate_hash", "position": "left"} # sibling at parent level
183
+ ]
184
+
185
+ # Calculate expected root
186
+ # First level: hash1 + hash2
187
+ level1 = hashlib.sha256(("hash1" + "hash2").encode()).hexdigest()
188
+ # Second level: intermediate_hash + level1
189
+ root_hash = hashlib.sha256(("intermediate_hash" + level1).encode()).hexdigest()
190
+
191
+ proof = MerkleProof(
192
+ leaf_hash=leaf_hash,
193
+ path=path,
194
+ root_hash=root_hash
195
+ )
196
+
197
+ assert proof.verify() is True
198
+
199
+ def test_proof_verification_failure(self):
200
+ """Test proof verification failure."""
201
+ proof = MerkleProof(
202
+ leaf_hash="hash1",
203
+ path=[{"hash": "hash2", "position": "left"}],
204
+ root_hash="wrong_root_hash"
205
+ )
206
+
207
+ assert proof.verify() is False
208
+
209
+
210
+ class TestVerifyMerkleProof:
211
+ """Test the standalone verify_merkle_proof function."""
212
+
213
+ def test_verify_merkle_proof_function(self):
214
+ """Test the standalone verify_merkle_proof function."""
215
+ import hashlib
216
+
217
+ leaf_hash = "hash1"
218
+ path = [{"hash": "hash2", "position": "left"}]
219
+ # When sibling is on the left, we concatenate sibling + leaf
220
+ combined = "hash2" + leaf_hash
221
+ root_hash = hashlib.sha256(combined.encode()).hexdigest()
222
+
223
+ assert verify_merkle_proof(leaf_hash, path, root_hash) is True
224
+
225
+ # Test with wrong root
226
+ assert verify_merkle_proof(leaf_hash, path, "wrong_root") is False
227
+
228
+ def test_verify_merkle_proof_empty_path(self):
229
+ """Test verify_merkle_proof with empty path (single leaf)."""
230
+ leaf_hash = "hash1"
231
+ path = []
232
+ root_hash = "hash1" # For single leaf, root equals leaf
233
+
234
+ assert verify_merkle_proof(leaf_hash, path, root_hash) is True
235
+ assert verify_merkle_proof(leaf_hash, path, "wrong_root") is False