paul-purecipher commited on
Commit
14641be
·
unverified ·
2 Parent(s): 589c58cd4e49d9

Merge branch 'main' into feature/reflexive-core

Browse files
examples/contract_example.py ADDED
@@ -0,0 +1,401 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastMCP Contract Management Example
3
+
4
+ This example demonstrates the inter-agent contract management system with:
5
+ - Contract lifecycle management (draft, propose, sign, revoke)
6
+ - Ed25519 cryptographic signatures for non-repudiation
7
+ - HIPAA compliance support
8
+ - SQLite persistence
9
+ - HTTP API endpoints
10
+ """
11
+
12
+ import asyncio
13
+ import json
14
+ from datetime import datetime, timedelta
15
+
16
+ from fastmcp import FastMCP
17
+ from fastmcp.contracts import ContractEngine, ContractState
18
+ from fastmcp.contracts.contract import (
19
+ Clause, ContractCreateRequest, ContractProposeRequest,
20
+ ContractSignRequest, ContractRevokeRequest
21
+ )
22
+ from fastmcp.contracts.crypto import generate_key_pair, Ed25519Signer
23
+
24
+
25
+ def create_contract_example():
26
+ """Create a FastMCP server with contract engine enabled."""
27
+
28
+ # Create server
29
+ server = FastMCP("Contract Management Server")
30
+
31
+ # Enable contract engine
32
+ contract_engine = server.enable_contract_engine()
33
+
34
+ # Add a simple tool that demonstrates contract operations
35
+ @server.tool
36
+ async def create_sample_contract(title: str, description: str) -> dict:
37
+ """Create a sample contract for demonstration."""
38
+
39
+ # Create sample clauses
40
+ clauses = [
41
+ Clause(
42
+ title="Data Protection",
43
+ content="All personal data must be protected according to HIPAA regulations.",
44
+ type="hipaa"
45
+ ),
46
+ Clause(
47
+ title="Access Control",
48
+ content="Only authorized personnel may access patient data.",
49
+ type="security"
50
+ ),
51
+ Clause(
52
+ title="Audit Trail",
53
+ content="All data access must be logged for audit purposes.",
54
+ type="compliance"
55
+ )
56
+ ]
57
+
58
+ # Create sample parties
59
+ parties = [
60
+ {
61
+ "id": "provider1",
62
+ "name": "Healthcare Provider Inc.",
63
+ "type": "provider",
64
+ "email": "provider@example.com",
65
+ "role": "data_controller"
66
+ },
67
+ {
68
+ "id": "patient1",
69
+ "name": "John Doe",
70
+ "type": "patient",
71
+ "email": "patient@example.com",
72
+ "role": "data_subject"
73
+ }
74
+ ]
75
+
76
+ # Create contract request
77
+ contract_request = ContractCreateRequest(
78
+ title=title,
79
+ description=description,
80
+ clauses=clauses,
81
+ parties=parties,
82
+ is_hipaa_compliant=True,
83
+ expires_at=datetime.utcnow() + timedelta(days=365),
84
+ metadata={
85
+ "created_by": "system",
86
+ "purpose": "data_sharing_agreement"
87
+ }
88
+ )
89
+
90
+ # Create contract
91
+ contract = await contract_engine.create_contract(contract_request, "system")
92
+
93
+ return {
94
+ "contract_id": str(contract.id),
95
+ "title": contract.title,
96
+ "state": contract.state.value,
97
+ "parties": [party["name"] for party in contract.get_parties()],
98
+ "clauses": [clause.title for clause in contract.get_clauses()],
99
+ "is_hipaa_compliant": contract.is_hipaa_compliant
100
+ }
101
+
102
+ return server
103
+
104
+
105
+ async def demonstrate_contract_lifecycle():
106
+ """Demonstrate complete contract lifecycle."""
107
+
108
+ server = create_contract_example()
109
+ contract_engine = server.get_contract_engine()
110
+ assert contract_engine is not None # Ensure contract engine is enabled
111
+
112
+ print("📋 FastMCP Contract Management Example")
113
+ print("=" * 50)
114
+
115
+ # 1. Create a contract
116
+ print("\n1️⃣ Creating Contract")
117
+ print("-" * 30)
118
+
119
+ clauses = [
120
+ Clause(
121
+ title="Data Sharing Agreement",
122
+ content="Healthcare provider may share patient data with authorized third parties.",
123
+ type="hipaa"
124
+ ),
125
+ Clause(
126
+ title="Consent Requirement",
127
+ content="Patient consent must be obtained before data sharing.",
128
+ type="consent"
129
+ )
130
+ ]
131
+
132
+ parties = [
133
+ {
134
+ "id": "provider1",
135
+ "name": "Metro Health System",
136
+ "type": "provider",
137
+ "email": "admin@metrohealth.com"
138
+ },
139
+ {
140
+ "id": "patient1",
141
+ "name": "Jane Smith",
142
+ "type": "patient",
143
+ "email": "jane.smith@email.com"
144
+ }
145
+ ]
146
+
147
+ contract_request = ContractCreateRequest(
148
+ title="HIPAA Data Sharing Agreement",
149
+ description="Agreement for sharing patient data between healthcare providers",
150
+ clauses=clauses,
151
+ parties=parties,
152
+ is_hipaa_compliant=True,
153
+ expires_at=datetime.utcnow() + timedelta(days=365)
154
+ )
155
+
156
+ contract = await contract_engine.create_contract(contract_request, "admin")
157
+ print(f"✅ Contract created: {contract.title}")
158
+ print(f" ID: {contract.id}")
159
+ print(f" State: {contract.state.value}")
160
+ print(f" Parties: {len(contract.get_parties())}")
161
+ print(f" HIPAA Compliant: {contract.is_hipaa_compliant}")
162
+
163
+ # 2. Propose the contract
164
+ print("\n2️⃣ Proposing Contract")
165
+ print("-" * 30)
166
+
167
+ proposal_request = ContractProposeRequest(
168
+ proposed_to=["provider1", "patient1"],
169
+ message="Please review and sign this data sharing agreement."
170
+ )
171
+
172
+ contract = await contract_engine.propose_contract(contract.id, proposal_request, "admin")
173
+ print(f"✅ Contract proposed to: {proposal_request.proposed_to}")
174
+ print(f" State: {contract.state.value}")
175
+ print(f" Proposed at: {contract.proposed_at}")
176
+
177
+ # 3. Sign the contract (Provider)
178
+ print("\n3️⃣ Signing Contract (Provider)")
179
+ print("-" * 30)
180
+
181
+ # Generate key pair for provider
182
+ provider_public_key, provider_private_key = generate_key_pair()
183
+ provider_signer = Ed25519Signer.from_private_key_b64(provider_private_key)
184
+
185
+ # Create signing message
186
+ signing_message = f"{contract.id}:{contract.get_content_hash()}:provider1:provider"
187
+ provider_signature = provider_signer.sign(signing_message)
188
+
189
+ sign_request = ContractSignRequest(
190
+ signer_id="provider1",
191
+ signer_type="provider",
192
+ public_key=provider_public_key,
193
+ signature=provider_signature
194
+ )
195
+
196
+ contract = await contract_engine.sign_contract(contract.id, sign_request)
197
+ print(f"✅ Provider signed the contract")
198
+ print(f" Signatures: {len(contract.get_signatures())}")
199
+ print(f" State: {contract.state.value}")
200
+
201
+ # 4. Sign the contract (Patient)
202
+ print("\n4️⃣ Signing Contract (Patient)")
203
+ print("-" * 30)
204
+
205
+ # Generate key pair for patient
206
+ patient_public_key, patient_private_key = generate_key_pair()
207
+ patient_signer = Ed25519Signer.from_private_key_b64(patient_private_key)
208
+
209
+ # Create signing message
210
+ signing_message = f"{contract.id}:{contract.get_content_hash()}:patient1:patient"
211
+ patient_signature = patient_signer.sign(signing_message)
212
+
213
+ sign_request = ContractSignRequest(
214
+ signer_id="patient1",
215
+ signer_type="patient",
216
+ public_key=patient_public_key,
217
+ signature=patient_signature
218
+ )
219
+
220
+ contract = await contract_engine.sign_contract(contract.id, sign_request)
221
+ print(f"✅ Patient signed the contract")
222
+ print(f" Signatures: {len(contract.get_signatures())}")
223
+ print(f" State: {contract.state.value}")
224
+ print(f" Fully signed: {contract.is_fully_signed()}")
225
+
226
+ # 5. Demonstrate contract verification
227
+ print("\n5️⃣ Contract Verification")
228
+ print("-" * 30)
229
+
230
+ signatures = contract.get_signatures()
231
+ for signature in signatures:
232
+ # Verify signature
233
+ signing_message = f"{contract.id}:{contract.get_content_hash()}:{signature.signer_id}:{signature.signer_type}"
234
+ is_valid = provider_signer.verify(signing_message, signature.signature)
235
+ print(f" {signature.signer_id} signature: {'✅ Valid' if is_valid else '❌ Invalid'}")
236
+
237
+ # 6. Get contract statistics
238
+ print("\n6️⃣ Contract Statistics")
239
+ print("-" * 30)
240
+
241
+ stats = await contract_engine.get_contract_statistics()
242
+ print(f" Total contracts: {stats['total_contracts']}")
243
+ print(f" By state: {stats['by_state']}")
244
+ print(f" HIPAA compliant: {stats['hipaa_compliant']}")
245
+ print(f" Signed contracts: {stats['signed_contracts']}")
246
+
247
+ # 7. Demonstrate revocation (optional)
248
+ print("\n7️⃣ Contract Revocation (Optional)")
249
+ print("-" * 30)
250
+
251
+ revoke_request = ContractRevokeRequest(
252
+ reason="Patient requested data deletion",
253
+ revoked_by="admin"
254
+ )
255
+
256
+ contract = await contract_engine.revoke_contract(contract.id, revoke_request)
257
+ print(f"✅ Contract revoked")
258
+ print(f" State: {contract.state.value}")
259
+ print(f" Revoked at: {contract.revoked_at}")
260
+ print(f" Reason: {revoke_request.reason}")
261
+
262
+
263
+ async def demonstrate_hipaa_compliance():
264
+ """Demonstrate HIPAA compliance features."""
265
+
266
+ print("\n🏥 HIPAA Compliance Demonstration")
267
+ print("=" * 50)
268
+
269
+ server = create_contract_example()
270
+ contract_engine = server.get_contract_engine()
271
+ assert contract_engine is not None
272
+
273
+ # Create HIPAA-compliant contract
274
+ hipaa_clauses = [
275
+ Clause(
276
+ title="HIPAA Privacy Rule",
277
+ content="All patient data must be handled according to HIPAA Privacy Rule requirements.",
278
+ type="hipaa",
279
+ metadata={"regulation": "45 CFR 164.502"}
280
+ ),
281
+ Clause(
282
+ title="Minimum Necessary Standard",
283
+ content="Only the minimum necessary information may be disclosed.",
284
+ type="hipaa",
285
+ metadata={"regulation": "45 CFR 164.502(b)"}
286
+ ),
287
+ Clause(
288
+ title="Business Associate Agreement",
289
+ content="Third parties must sign a Business Associate Agreement.",
290
+ type="hipaa",
291
+ metadata={"regulation": "45 CFR 164.502(e)"}
292
+ )
293
+ ]
294
+
295
+ hipaa_parties = [
296
+ {
297
+ "id": "covered_entity",
298
+ "name": "Regional Medical Center",
299
+ "type": "covered_entity",
300
+ "email": "privacy@regionalmedical.com",
301
+ "hipaa_role": "covered_entity"
302
+ },
303
+ {
304
+ "id": "business_associate",
305
+ "name": "HealthTech Solutions",
306
+ "type": "business_associate",
307
+ "email": "compliance@healthtech.com",
308
+ "hipaa_role": "business_associate"
309
+ }
310
+ ]
311
+
312
+ contract_request = ContractCreateRequest(
313
+ title="HIPAA Business Associate Agreement",
314
+ description="Agreement for HIPAA-compliant data processing services",
315
+ clauses=hipaa_clauses,
316
+ parties=hipaa_parties,
317
+ is_hipaa_compliant=True,
318
+ hipaa_entities=[
319
+ {
320
+ "type": "covered_entity",
321
+ "name": "Regional Medical Center",
322
+ "hipaa_id": "CE-001"
323
+ },
324
+ {
325
+ "type": "business_associate",
326
+ "name": "HealthTech Solutions",
327
+ "hipaa_id": "BA-001"
328
+ }
329
+ ],
330
+ metadata={
331
+ "hipaa_version": "2023",
332
+ "compliance_level": "full",
333
+ "audit_required": True
334
+ }
335
+ )
336
+
337
+ contract = await contract_engine.create_contract(contract_request, "hipaa_admin")
338
+
339
+ print(f"✅ HIPAA-compliant contract created")
340
+ print(f" Title: {contract.title}")
341
+ print(f" HIPAA Compliant: {contract.is_hipaa_compliant}")
342
+ print(f" HIPAA Entities: {len(contract.get_hipaa_entities())}")
343
+ print(f" Clauses: {len(contract.get_clauses())}")
344
+
345
+ # Show HIPAA-specific metadata
346
+ metadata = contract.get_metadata()
347
+ print(f" Compliance Level: {metadata.get('compliance_level')}")
348
+ print(f" Audit Required: {metadata.get('audit_required')}")
349
+
350
+
351
+ def main():
352
+ """Run the contract management example."""
353
+
354
+ async def run_example():
355
+ # Demonstrate contract lifecycle
356
+ await demonstrate_contract_lifecycle()
357
+
358
+ # Demonstrate HIPAA compliance
359
+ await demonstrate_hipaa_compliance()
360
+
361
+ print("\n🚀 Contract Management Example Complete!")
362
+ print("\nTo test the HTTP endpoints:")
363
+ print("1. Run: fastmcp run examples/contract_example.py --transport http")
364
+ print("2. Available endpoints:")
365
+ print(" - POST /contracts - Create contract")
366
+ print(" - GET /contracts - List contracts")
367
+ print(" - GET /contracts/{id} - Get contract")
368
+ print(" - POST /contracts/{id}/propose - Propose contract")
369
+ print(" - POST /contracts/{id}/sign - Sign contract")
370
+ print(" - POST /contracts/{id}/revoke - Revoke contract")
371
+ print(" - GET /contracts/statistics - Get statistics")
372
+ print("\n3. Example contract creation:")
373
+ print("""
374
+ curl -X POST http://localhost:8000/contracts \\
375
+ -H "Content-Type: application/json" \\
376
+ -d '{
377
+ "title": "Test Contract",
378
+ "description": "A test contract",
379
+ "clauses": [
380
+ {
381
+ "title": "Test Clause",
382
+ "content": "This is a test clause",
383
+ "type": "test"
384
+ }
385
+ ],
386
+ "parties": [
387
+ {
388
+ "id": "party1",
389
+ "name": "Test Party",
390
+ "type": "provider"
391
+ }
392
+ ],
393
+ "is_hipaa_compliant": false
394
+ }'
395
+ """)
396
+
397
+ asyncio.run(run_example())
398
+
399
+
400
+ if __name__ == "__main__":
401
+ main()
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/contracts/__init__.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ """FastMCP Contract Management - Inter-agent contract lifecycle with cryptographic guarantees."""
2
+
3
+ from .contract import Contract, Clause, Signature, ContractState
4
+ from .engine import ContractEngine
5
+ from .registry import ContractRegistry
6
+
7
+ __all__ = ["Contract", "Clause", "Signature", "ContractState", "ContractEngine", "ContractRegistry"]
src/fastmcp/contracts/contract.py ADDED
@@ -0,0 +1,306 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Contract models and schemas for inter-agent contract management."""
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, validator
11
+ from sqlmodel import SQLModel, Field as SQLField, Relationship
12
+
13
+
14
+ class ContractState(str, Enum):
15
+ """Contract lifecycle states."""
16
+ DRAFT = "draft"
17
+ PROPOSED = "proposed"
18
+ SIGNED = "signed"
19
+ REVOKED = "revoked"
20
+ EXPIRED = "expired"
21
+
22
+
23
+ class Clause(BaseModel):
24
+ """A contract clause with structured content."""
25
+
26
+ id: str = Field(default_factory=lambda: str(uuid4()))
27
+ title: str = Field(..., description="Clause title")
28
+ content: str = Field(..., description="Clause content")
29
+ type: str = Field(default="general", description="Clause type (e.g., 'hipaa', 'data_handling')")
30
+ metadata: Dict[str, Any] = Field(default_factory=dict, description="Additional clause metadata")
31
+
32
+ class Config:
33
+ json_encoders = {
34
+ datetime: lambda v: v.isoformat()
35
+ }
36
+
37
+
38
+ class Signature(BaseModel):
39
+ """Cryptographic signature for contract verification."""
40
+
41
+ signer_id: str = Field(..., description="ID of the signing party")
42
+ signer_type: str = Field(..., description="Type of signer (e.g., 'provider', 'payor', 'patient')")
43
+ signature: str = Field(..., description="Base64-encoded Ed25519 signature")
44
+ public_key: str = Field(..., description="Base64-encoded public key")
45
+ timestamp: datetime = Field(default_factory=datetime.utcnow)
46
+ metadata: Dict[str, Any] = Field(default_factory=dict)
47
+
48
+ class Config:
49
+ json_encoders = {
50
+ datetime: lambda v: v.isoformat()
51
+ }
52
+
53
+ def model_dump(self, **kwargs):
54
+ """Override model_dump to handle datetime serialization."""
55
+ data = super().model_dump(**kwargs)
56
+ # Convert datetime objects to ISO format strings
57
+ for key, value in data.items():
58
+ if isinstance(value, datetime):
59
+ data[key] = value.isoformat()
60
+ return data
61
+
62
+
63
+ class Contract(SQLModel, table=True):
64
+ """Contract model with SQLModel persistence."""
65
+
66
+ __tablename__ = "contracts"
67
+
68
+ # Primary fields
69
+ id: UUID = SQLField(default_factory=uuid4, primary_key=True)
70
+ title: str = SQLField(..., description="Contract title")
71
+ description: str = SQLField(..., description="Contract description")
72
+
73
+ # Contract content
74
+ clauses: str = SQLField(..., description="JSON-encoded clauses")
75
+ parties: str = SQLField(..., description="JSON-encoded parties")
76
+
77
+ # Lifecycle
78
+ state: ContractState = SQLField(default=ContractState.DRAFT)
79
+ created_at: datetime = SQLField(default_factory=datetime.utcnow)
80
+ proposed_at: Optional[datetime] = SQLField(default=None)
81
+ signed_at: Optional[datetime] = SQLField(default=None)
82
+ revoked_at: Optional[datetime] = SQLField(default=None)
83
+ expires_at: Optional[datetime] = SQLField(default=None)
84
+
85
+ # Signatures
86
+ signatures: str = SQLField(default="[]", description="JSON-encoded signatures")
87
+
88
+ # HIPAA compliance
89
+ is_hipaa_compliant: bool = SQLField(default=False)
90
+ hipaa_entities: str = SQLField(default="[]", description="JSON-encoded HIPAA entities")
91
+
92
+ # Metadata
93
+ contract_metadata: str = SQLField(default="{}", description="JSON-encoded metadata")
94
+ version: str = SQLField(default="1.0.0")
95
+
96
+ # Audit trail
97
+ created_by: str = SQLField(..., description="ID of the creating party")
98
+ last_modified: datetime = SQLField(default_factory=datetime.utcnow)
99
+
100
+ def get_clauses(self) -> List[Clause]:
101
+ """Get parsed clauses from JSON."""
102
+ try:
103
+ clauses_data = json.loads(self.clauses)
104
+ return [Clause(**clause) for clause in clauses_data]
105
+ except (json.JSONDecodeError, ValueError):
106
+ return []
107
+
108
+ def set_clauses(self, clauses: List[Clause]) -> None:
109
+ """Set clauses as JSON."""
110
+ self.clauses = json.dumps([clause.model_dump() for clause in clauses])
111
+
112
+ def get_parties(self) -> List[Dict[str, Any]]:
113
+ """Get parsed parties from JSON."""
114
+ try:
115
+ return json.loads(self.parties)
116
+ except json.JSONDecodeError:
117
+ return []
118
+
119
+ def set_parties(self, parties: List[Dict[str, Any]]) -> None:
120
+ """Set parties as JSON."""
121
+ self.parties = json.dumps(parties)
122
+
123
+ def get_signatures(self) -> List[Signature]:
124
+ """Get parsed signatures from JSON."""
125
+ try:
126
+ signatures_data = json.loads(self.signatures)
127
+ return [Signature(**sig) for sig in signatures_data]
128
+ except (json.JSONDecodeError, ValueError):
129
+ return []
130
+
131
+ def set_signatures(self, signatures: List[Signature]) -> None:
132
+ """Set signatures as JSON."""
133
+ self.signatures = json.dumps([sig.model_dump() for sig in signatures])
134
+
135
+ def get_hipaa_entities(self) -> List[Dict[str, Any]]:
136
+ """Get parsed HIPAA entities from JSON."""
137
+ try:
138
+ return json.loads(self.hipaa_entities)
139
+ except json.JSONDecodeError:
140
+ return []
141
+
142
+ def set_hipaa_entities(self, entities: List[Dict[str, Any]]) -> None:
143
+ """Set HIPAA entities as JSON."""
144
+ self.hipaa_entities = json.dumps(entities)
145
+
146
+ def get_metadata(self) -> Dict[str, Any]:
147
+ """Get parsed metadata from JSON."""
148
+ try:
149
+ return json.loads(self.contract_metadata)
150
+ except json.JSONDecodeError:
151
+ return {}
152
+
153
+ def set_metadata(self, metadata: Dict[str, Any]) -> None:
154
+ """Set metadata as JSON."""
155
+ self.contract_metadata = json.dumps(metadata)
156
+
157
+ def get_content_hash(self) -> str:
158
+ """Get SHA-256 hash of contract content for signing."""
159
+ content = {
160
+ "id": str(self.id),
161
+ "title": self.title,
162
+ "description": self.description,
163
+ "clauses": self.clauses,
164
+ "parties": self.parties,
165
+ "version": self.version
166
+ }
167
+ content_str = json.dumps(content, sort_keys=True)
168
+ return hashlib.sha256(content_str.encode()).hexdigest()
169
+
170
+ def can_transition_to(self, new_state: ContractState) -> bool:
171
+ """Check if contract can transition to new state."""
172
+ valid_transitions = {
173
+ ContractState.DRAFT: [ContractState.PROPOSED, ContractState.REVOKED],
174
+ ContractState.PROPOSED: [ContractState.SIGNED, ContractState.REVOKED, ContractState.DRAFT],
175
+ ContractState.SIGNED: [ContractState.REVOKED],
176
+ ContractState.REVOKED: [], # Terminal state
177
+ ContractState.EXPIRED: [] # Terminal state
178
+ }
179
+ return new_state in valid_transitions.get(self.state, [])
180
+
181
+ def is_fully_signed(self) -> bool:
182
+ """Check if contract is fully signed by all required parties."""
183
+ parties = self.get_parties()
184
+ signatures = self.get_signatures()
185
+
186
+ # Check if all parties have signed
187
+ signed_party_ids = {sig.signer_id for sig in signatures}
188
+ required_party_ids = {party["id"] for party in parties}
189
+
190
+ return required_party_ids.issubset(signed_party_ids)
191
+
192
+ def get_unsigned_parties(self) -> List[Dict[str, Any]]:
193
+ """Get parties that haven't signed yet."""
194
+ parties = self.get_parties()
195
+ signatures = self.get_signatures()
196
+ signed_party_ids = {sig.signer_id for sig in signatures}
197
+
198
+ return [party for party in parties if party["id"] not in signed_party_ids]
199
+
200
+
201
+ class ContractCreateRequest(BaseModel):
202
+ """Request model for creating a contract."""
203
+
204
+ title: str = Field(..., description="Contract title")
205
+ description: str = Field(..., description="Contract description")
206
+ clauses: List[Clause] = Field(..., description="Contract clauses")
207
+ parties: List[Dict[str, Any]] = Field(..., description="Contract parties")
208
+ is_hipaa_compliant: bool = Field(default=False, description="HIPAA compliance flag")
209
+ hipaa_entities: Optional[List[Dict[str, Any]]] = Field(default=None, description="HIPAA entities")
210
+ expires_at: Optional[datetime] = Field(default=None, description="Contract expiration")
211
+ metadata: Dict[str, Any] = Field(default_factory=dict, description="Additional metadata")
212
+ version: str = Field(default="1.0.0", description="Contract version")
213
+
214
+
215
+ class ContractProposeRequest(BaseModel):
216
+ """Request model for proposing a contract."""
217
+
218
+ proposed_to: List[str] = Field(..., description="IDs of parties to propose to")
219
+ message: Optional[str] = Field(default=None, description="Proposal message")
220
+
221
+
222
+ class ContractSignRequest(BaseModel):
223
+ """Request model for signing a contract."""
224
+
225
+ signer_id: str = Field(..., description="ID of the signing party")
226
+ signer_type: str = Field(..., description="Type of signer")
227
+ public_key: str = Field(..., description="Base64-encoded public key")
228
+ signature: str = Field(..., description="Base64-encoded Ed25519 signature")
229
+ metadata: Dict[str, Any] = Field(default_factory=dict, description="Signature metadata")
230
+
231
+
232
+ class ContractRevokeRequest(BaseModel):
233
+ """Request model for revoking a contract."""
234
+
235
+ reason: str = Field(..., description="Reason for revocation")
236
+ revoked_by: str = Field(..., description="ID of the revoking party")
237
+ metadata: Dict[str, Any] = Field(default_factory=dict, description="Revocation metadata")
238
+
239
+
240
+ class ContractResponse(BaseModel):
241
+ """Response model for contract operations."""
242
+
243
+ id: str
244
+ title: str
245
+ description: str
246
+ clauses: List[Clause]
247
+ parties: List[Dict[str, Any]]
248
+ state: ContractState
249
+ created_at: datetime
250
+ proposed_at: Optional[datetime]
251
+ signed_at: Optional[datetime]
252
+ revoked_at: Optional[datetime]
253
+ expires_at: Optional[datetime]
254
+ signatures: List[Dict[str, Any]]
255
+ is_hipaa_compliant: bool
256
+ hipaa_entities: List[Dict[str, Any]]
257
+ contract_metadata: Dict[str, Any]
258
+ version: str
259
+ created_by: str
260
+ last_modified: datetime
261
+ content_hash: str
262
+ is_fully_signed: bool
263
+ unsigned_parties: List[Dict[str, Any]]
264
+
265
+ def model_dump(self, **kwargs):
266
+ """Override model_dump to handle datetime serialization."""
267
+ data = super().model_dump(**kwargs)
268
+ # Convert datetime objects to ISO format strings
269
+ for key, value in data.items():
270
+ if isinstance(value, datetime):
271
+ data[key] = value.isoformat()
272
+ elif key == "signatures" and isinstance(value, list):
273
+ # Handle nested Signature objects
274
+ data[key] = [sig.model_dump() if hasattr(sig, 'model_dump') else sig for sig in value]
275
+ return data
276
+
277
+ @classmethod
278
+ def from_contract(cls, contract: Contract) -> "ContractResponse":
279
+ """Create response from contract model."""
280
+ # Pre-serialize signatures to handle datetime fields
281
+ signatures = contract.get_signatures()
282
+ serialized_signatures = [sig.model_dump() for sig in signatures]
283
+
284
+ return cls(
285
+ id=str(contract.id), # Convert UUID to string
286
+ title=contract.title,
287
+ description=contract.description,
288
+ clauses=contract.get_clauses(),
289
+ parties=contract.get_parties(),
290
+ state=contract.state,
291
+ created_at=contract.created_at,
292
+ proposed_at=contract.proposed_at,
293
+ signed_at=contract.signed_at,
294
+ revoked_at=contract.revoked_at,
295
+ expires_at=contract.expires_at,
296
+ signatures=serialized_signatures, # Use pre-serialized signatures
297
+ is_hipaa_compliant=contract.is_hipaa_compliant,
298
+ hipaa_entities=contract.get_hipaa_entities(),
299
+ contract_metadata=contract.get_metadata(),
300
+ version=contract.version,
301
+ created_by=contract.created_by,
302
+ last_modified=contract.last_modified,
303
+ content_hash=contract.get_content_hash(),
304
+ is_fully_signed=contract.is_fully_signed(),
305
+ unsigned_parties=contract.get_unsigned_parties()
306
+ )
src/fastmcp/contracts/crypto.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Cryptographic utilities for contract signing and verification."""
2
+
3
+ import base64
4
+ from typing import Tuple
5
+
6
+ import nacl.encoding
7
+ import nacl.signing
8
+ from nacl.exceptions import BadSignatureError
9
+
10
+ from fastmcp.utilities.logging import get_logger
11
+
12
+ logger = get_logger(__name__)
13
+
14
+
15
+ class CryptoError(Exception):
16
+ """Cryptographic operation error."""
17
+ pass
18
+
19
+
20
+ class Ed25519Signer:
21
+ """Ed25519 signature operations for contract signing."""
22
+
23
+ def __init__(self, private_key: bytes = None):
24
+ """Initialize signer with optional private key.
25
+
26
+ Args:
27
+ private_key: Optional private key bytes. If None, generates new key pair.
28
+ """
29
+ if private_key is None:
30
+ self._signing_key = nacl.signing.SigningKey.generate()
31
+ else:
32
+ self._signing_key = nacl.signing.SigningKey(private_key)
33
+
34
+ self._verify_key = self._signing_key.verify_key
35
+
36
+ @classmethod
37
+ def from_private_key_b64(cls, private_key_b64: str) -> "Ed25519Signer":
38
+ """Create signer from base64-encoded private key.
39
+
40
+ Args:
41
+ private_key_b64: Base64-encoded private key
42
+
43
+ Returns:
44
+ Ed25519Signer instance
45
+ """
46
+ try:
47
+ private_key = base64.b64decode(private_key_b64)
48
+ return cls(private_key)
49
+ except Exception as e:
50
+ raise CryptoError(f"Invalid private key format: {e}")
51
+
52
+ @classmethod
53
+ def from_public_key_b64(cls, public_key_b64: str) -> "Ed25519Signer":
54
+ """Create signer from base64-encoded public key (verification only).
55
+
56
+ Args:
57
+ public_key_b64: Base64-encoded public key
58
+
59
+ Returns:
60
+ Ed25519Signer instance (verification only)
61
+ """
62
+ try:
63
+ public_key = base64.b64decode(public_key_b64)
64
+ verify_key = nacl.signing.VerifyKey(public_key)
65
+ signer = cls.__new__(cls)
66
+ signer._signing_key = None # No private key for verification only
67
+ signer._verify_key = verify_key
68
+ return signer
69
+ except Exception as e:
70
+ raise CryptoError(f"Invalid public key format: {e}")
71
+
72
+ def sign(self, message: str) -> str:
73
+ """Sign a message.
74
+
75
+ Args:
76
+ message: Message to sign
77
+
78
+ Returns:
79
+ Base64-encoded signature
80
+
81
+ Raises:
82
+ CryptoError: If signing fails
83
+ """
84
+ if self._signing_key is None:
85
+ raise CryptoError("Cannot sign: no private key available")
86
+
87
+ try:
88
+ message_bytes = message.encode('utf-8')
89
+ signed = self._signing_key.sign(message_bytes)
90
+ signature = signed.signature
91
+ return base64.b64encode(signature).decode('ascii')
92
+ except Exception as e:
93
+ raise CryptoError(f"Signing failed: {e}")
94
+
95
+ def verify(self, message: str, signature: str) -> bool:
96
+ """Verify a signature.
97
+
98
+ Args:
99
+ message: Original message
100
+ signature: Base64-encoded signature to verify
101
+
102
+ Returns:
103
+ True if signature is valid, False otherwise
104
+ """
105
+ try:
106
+ message_bytes = message.encode('utf-8')
107
+ signature_bytes = base64.b64decode(signature)
108
+ self._verify_key.verify(message_bytes, signature_bytes)
109
+ return True
110
+ except (BadSignatureError, Exception) as e:
111
+ logger.debug(f"Signature verification failed: {e}")
112
+ return False
113
+
114
+ def get_public_key_b64(self) -> str:
115
+ """Get base64-encoded public key.
116
+
117
+ Returns:
118
+ Base64-encoded public key
119
+ """
120
+ return base64.b64encode(self._verify_key.encode()).decode('ascii')
121
+
122
+ def get_private_key_b64(self) -> str:
123
+ """Get base64-encoded private key.
124
+
125
+ Returns:
126
+ Base64-encoded private key
127
+
128
+ Raises:
129
+ CryptoError: If no private key available
130
+ """
131
+ if self._signing_key is None:
132
+ raise CryptoError("No private key available")
133
+
134
+ return base64.b64encode(self._signing_key.encode()).decode('ascii')
135
+
136
+ def get_key_pair_b64(self) -> Tuple[str, str]:
137
+ """Get both public and private keys as base64 strings.
138
+
139
+ Returns:
140
+ Tuple of (public_key_b64, private_key_b64)
141
+
142
+ Raises:
143
+ CryptoError: If no private key available
144
+ """
145
+ return self.get_public_key_b64(), self.get_private_key_b64()
146
+
147
+
148
+ class ContractSigner:
149
+ """High-level contract signing operations."""
150
+
151
+ def __init__(self, signer: Ed25519Signer):
152
+ """Initialize with Ed25519 signer.
153
+
154
+ Args:
155
+ signer: Ed25519Signer instance
156
+ """
157
+ self._signer = signer
158
+
159
+ def sign_contract(self, contract_id: str, content_hash: str, signer_id: str, signer_type: str) -> str:
160
+ """Sign a contract.
161
+
162
+ Args:
163
+ contract_id: Contract ID
164
+ content_hash: SHA-256 hash of contract content
165
+ signer_id: ID of the signing party
166
+ signer_type: Type of signer (e.g., 'provider', 'payor', 'patient')
167
+
168
+ Returns:
169
+ Base64-encoded signature
170
+
171
+ Raises:
172
+ CryptoError: If signing fails
173
+ """
174
+ # Create signing message
175
+ signing_message = f"{contract_id}:{content_hash}:{signer_id}:{signer_type}"
176
+ return self._signer.sign(signing_message)
177
+
178
+ def verify_contract_signature(self, contract_id: str, content_hash: str, signer_id: str,
179
+ signer_type: str, signature: str) -> bool:
180
+ """Verify a contract signature.
181
+
182
+ Args:
183
+ contract_id: Contract ID
184
+ content_hash: SHA-256 hash of contract content
185
+ signer_id: ID of the signing party
186
+ signer_type: Type of signer
187
+ signature: Base64-encoded signature to verify
188
+
189
+ Returns:
190
+ True if signature is valid, False otherwise
191
+ """
192
+ # Create signing message
193
+ signing_message = f"{contract_id}:{content_hash}:{signer_id}:{signer_type}"
194
+ return self._signer.verify(signing_message, signature)
195
+
196
+ def get_public_key_b64(self) -> str:
197
+ """Get base64-encoded public key."""
198
+ return self._signer.get_public_key_b64()
199
+
200
+ def get_private_key_b64(self) -> str:
201
+ """Get base64-encoded private key."""
202
+ return self._signer.get_private_key_b64()
203
+
204
+
205
+ def generate_key_pair() -> Tuple[str, str]:
206
+ """Generate a new Ed25519 key pair.
207
+
208
+ Returns:
209
+ Tuple of (public_key_b64, private_key_b64)
210
+ """
211
+ signer = Ed25519Signer()
212
+ return signer.get_key_pair_b64()
213
+
214
+
215
+ def verify_signature(public_key_b64: str, message: str, signature: str) -> bool:
216
+ """Verify a signature with a public key.
217
+
218
+ Args:
219
+ public_key_b64: Base64-encoded public key
220
+ message: Original message
221
+ signature: Base64-encoded signature
222
+
223
+ Returns:
224
+ True if signature is valid, False otherwise
225
+ """
226
+ try:
227
+ signer = Ed25519Signer.from_public_key_b64(public_key_b64)
228
+ return signer.verify(message, signature)
229
+ except CryptoError:
230
+ return False
src/fastmcp/contracts/engine.py ADDED
@@ -0,0 +1,357 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Contract engine for managing contract lifecycle and operations."""
2
+
3
+ from datetime import datetime
4
+ from typing import List, Optional
5
+ from uuid import UUID
6
+
7
+ from sqlmodel import Session, create_engine
8
+
9
+ from fastmcp.utilities.logging import get_logger
10
+
11
+ from .contract import Contract, ContractState, ContractCreateRequest, ContractProposeRequest, ContractSignRequest, ContractRevokeRequest
12
+ from .crypto import ContractSigner, CryptoError
13
+ from .registry import ContractRegistry
14
+
15
+ logger = get_logger(__name__)
16
+
17
+
18
+ class ContractEngine:
19
+ """Engine for managing contract lifecycle and operations."""
20
+
21
+ def __init__(self, database_url: str = "sqlite:///contracts.db"):
22
+ """Initialize contract engine with database connection.
23
+
24
+ Args:
25
+ database_url: Database connection URL
26
+ """
27
+ self.engine = create_engine(database_url, echo=False)
28
+ self._create_tables()
29
+
30
+ def _create_tables(self):
31
+ """Create database tables."""
32
+ try:
33
+ Contract.metadata.create_all(self.engine)
34
+ logger.info("Contract database tables created/verified")
35
+ except Exception as e:
36
+ logger.error(f"Failed to create contract tables: {e}")
37
+ raise
38
+
39
+ def get_session(self) -> Session:
40
+ """Get database session.
41
+
42
+ Returns:
43
+ SQLModel database session
44
+ """
45
+ return Session(self.engine)
46
+
47
+ def get_registry(self) -> ContractRegistry:
48
+ """Get contract registry.
49
+
50
+ Returns:
51
+ Contract registry instance
52
+ """
53
+ return ContractRegistry(self.get_session())
54
+
55
+ async def create_contract(self, request: ContractCreateRequest, created_by: str) -> Contract:
56
+ """Create a new contract.
57
+
58
+ Args:
59
+ request: Contract creation request
60
+ created_by: ID of the creating party
61
+
62
+ Returns:
63
+ Created contract instance
64
+
65
+ Raises:
66
+ ValueError: If contract creation fails
67
+ """
68
+ try:
69
+ registry = self.get_registry()
70
+
71
+ # Convert request to contract data
72
+ contract_data = {
73
+ "title": request.title,
74
+ "description": request.description,
75
+ "clauses": [clause.model_dump() for clause in request.clauses],
76
+ "parties": request.parties,
77
+ "is_hipaa_compliant": request.is_hipaa_compliant,
78
+ "hipaa_entities": request.hipaa_entities or [],
79
+ "expires_at": request.expires_at,
80
+ "metadata": request.metadata,
81
+ "version": request.version
82
+ }
83
+
84
+ contract = registry.create_contract(contract_data, created_by)
85
+ logger.info(f"Created contract {contract.id} by {created_by}")
86
+ return contract
87
+
88
+ except Exception as e:
89
+ logger.error(f"Failed to create contract: {e}")
90
+ raise
91
+
92
+ async def get_contract(self, contract_id: UUID) -> Optional[Contract]:
93
+ """Get a contract by ID.
94
+
95
+ Args:
96
+ contract_id: Contract UUID
97
+
98
+ Returns:
99
+ Contract instance or None if not found
100
+ """
101
+ try:
102
+ registry = self.get_registry()
103
+ return registry.get_contract(contract_id)
104
+ except Exception as e:
105
+ logger.error(f"Failed to get contract {contract_id}: {e}")
106
+ return None
107
+
108
+ async def list_contracts(self, state: Optional[ContractState] = None,
109
+ created_by: Optional[str] = None) -> List[Contract]:
110
+ """List contracts with optional filtering.
111
+
112
+ Args:
113
+ state: Optional state filter
114
+ created_by: Optional creator filter
115
+
116
+ Returns:
117
+ List of contract instances
118
+ """
119
+ try:
120
+ registry = self.get_registry()
121
+ return registry.list_contracts(state, created_by)
122
+ except Exception as e:
123
+ logger.error(f"Failed to list contracts: {e}")
124
+ return []
125
+
126
+ async def propose_contract(self, contract_id: UUID, request: ContractProposeRequest,
127
+ proposed_by: str) -> Optional[Contract]:
128
+ """Propose a contract to parties.
129
+
130
+ Args:
131
+ contract_id: Contract UUID
132
+ request: Proposal request
133
+ proposed_by: ID of the proposing party
134
+
135
+ Returns:
136
+ Updated contract instance or None if not found
137
+
138
+ Raises:
139
+ ValueError: If proposal fails
140
+ """
141
+ try:
142
+ registry = self.get_registry()
143
+
144
+ # Validate contract exists and can be proposed
145
+ contract = registry.get_contract(contract_id)
146
+ if not contract:
147
+ raise ValueError("Contract not found")
148
+
149
+ if contract.state != ContractState.DRAFT:
150
+ raise ValueError(f"Cannot propose contract in state {contract.state}")
151
+
152
+ # Update metadata with proposal info
153
+ metadata = {
154
+ "proposal": {
155
+ "proposed_to": request.proposed_to,
156
+ "message": request.message,
157
+ "proposed_by": proposed_by,
158
+ "timestamp": datetime.utcnow().isoformat()
159
+ }
160
+ }
161
+
162
+ # Update state to proposed
163
+ updated_contract = registry.update_contract_state(
164
+ contract_id, ContractState.PROPOSED, proposed_by, metadata
165
+ )
166
+
167
+ logger.info(f"Proposed contract {contract_id} to {request.proposed_to} by {proposed_by}")
168
+ return updated_contract
169
+
170
+ except Exception as e:
171
+ logger.error(f"Failed to propose contract {contract_id}: {e}")
172
+ raise
173
+
174
+ async def sign_contract(self, contract_id: UUID, request: ContractSignRequest) -> Optional[Contract]:
175
+ """Sign a contract.
176
+
177
+ Args:
178
+ contract_id: Contract UUID
179
+ request: Signing request
180
+
181
+ Returns:
182
+ Updated contract instance or None if not found
183
+
184
+ Raises:
185
+ ValueError: If signing fails
186
+ """
187
+ try:
188
+ registry = self.get_registry()
189
+
190
+ # Validate contract exists and can be signed
191
+ contract = registry.get_contract(contract_id)
192
+ if not contract:
193
+ raise ValueError("Contract not found")
194
+
195
+ if contract.state not in [ContractState.PROPOSED, ContractState.SIGNED]:
196
+ raise ValueError(f"Cannot sign contract in state {contract.state}")
197
+
198
+ # Check if party is already signed
199
+ existing_signatures = contract.get_signatures()
200
+ if any(sig.signer_id == request.signer_id for sig in existing_signatures):
201
+ raise ValueError("Party has already signed this contract")
202
+
203
+ # Verify signature
204
+ if not self._verify_contract_signature(contract, request):
205
+ raise ValueError("Invalid signature")
206
+
207
+ # Create signature object
208
+ from .contract import Signature
209
+ signature = Signature(
210
+ signer_id=request.signer_id,
211
+ signer_type=request.signer_type,
212
+ signature=request.signature,
213
+ public_key=request.public_key,
214
+ metadata=request.metadata
215
+ )
216
+
217
+ # Add signature
218
+ updated_contract = registry.add_signature(contract_id, signature.model_dump())
219
+
220
+ logger.info(f"Signed contract {contract_id} by {request.signer_id}")
221
+ return updated_contract
222
+
223
+ except Exception as e:
224
+ logger.error(f"Failed to sign contract {contract_id}: {e}")
225
+ raise
226
+
227
+ async def revoke_contract(self, contract_id: UUID, request: ContractRevokeRequest) -> Optional[Contract]:
228
+ """Revoke a contract.
229
+
230
+ Args:
231
+ contract_id: Contract UUID
232
+ request: Revocation request
233
+
234
+ Returns:
235
+ Updated contract instance or None if not found
236
+
237
+ Raises:
238
+ ValueError: If revocation fails
239
+ """
240
+ try:
241
+ registry = self.get_registry()
242
+
243
+ # Validate contract exists and can be revoked
244
+ contract = registry.get_contract(contract_id)
245
+ if not contract:
246
+ raise ValueError("Contract not found")
247
+
248
+ if contract.state == ContractState.REVOKED:
249
+ raise ValueError("Contract is already revoked")
250
+
251
+ if contract.state == ContractState.EXPIRED:
252
+ raise ValueError("Cannot revoke expired contract")
253
+
254
+ # Revoke contract
255
+ updated_contract = registry.revoke_contract(
256
+ contract_id, request.reason, request.revoked_by, request.metadata
257
+ )
258
+
259
+ logger.info(f"Revoked contract {contract_id} by {request.revoked_by}: {request.reason}")
260
+ return updated_contract
261
+
262
+ except Exception as e:
263
+ logger.error(f"Failed to revoke contract {contract_id}: {e}")
264
+ raise
265
+
266
+ def _verify_contract_signature(self, contract: Contract, request: ContractSignRequest) -> bool:
267
+ """Verify a contract signature.
268
+
269
+ Args:
270
+ contract: Contract instance
271
+ request: Signing request
272
+
273
+ Returns:
274
+ True if signature is valid, False otherwise
275
+ """
276
+ try:
277
+ from .crypto import verify_signature
278
+
279
+ # Create signing message
280
+ signing_message = f"{contract.id}:{contract.get_content_hash()}:{request.signer_id}:{request.signer_type}"
281
+
282
+ # Verify signature
283
+ return verify_signature(
284
+ request.public_key,
285
+ signing_message,
286
+ request.signature
287
+ )
288
+
289
+ except Exception as e:
290
+ logger.error(f"Signature verification failed: {e}")
291
+ return False
292
+
293
+ async def get_contracts_by_party(self, party_id: str) -> List[Contract]:
294
+ """Get contracts involving a specific party.
295
+
296
+ Args:
297
+ party_id: Party ID to search for
298
+
299
+ Returns:
300
+ List of contracts involving the party
301
+ """
302
+ try:
303
+ registry = self.get_registry()
304
+ return registry.get_contracts_by_party(party_id)
305
+ except Exception as e:
306
+ logger.error(f"Failed to get contracts for party {party_id}: {e}")
307
+ return []
308
+
309
+ async def cleanup_expired_contracts(self) -> int:
310
+ """Mark expired contracts as expired.
311
+
312
+ Returns:
313
+ Number of contracts marked as expired
314
+ """
315
+ try:
316
+ registry = self.get_registry()
317
+ count = registry.mark_contracts_expired()
318
+ logger.info(f"Marked {count} contracts as expired")
319
+ return count
320
+ except Exception as e:
321
+ logger.error(f"Failed to cleanup expired contracts: {e}")
322
+ return 0
323
+
324
+ async def get_contract_statistics(self) -> dict:
325
+ """Get contract statistics.
326
+
327
+ Returns:
328
+ Dictionary with contract statistics
329
+ """
330
+ try:
331
+ registry = self.get_registry()
332
+
333
+ # Get all contracts
334
+ all_contracts = registry.list_contracts()
335
+
336
+ # Count by state
337
+ state_counts = {}
338
+ for state in ContractState:
339
+ state_counts[state.value] = len([c for c in all_contracts if c.state == state])
340
+
341
+ # Count HIPAA contracts
342
+ hipaa_count = len([c for c in all_contracts if c.is_hipaa_compliant])
343
+
344
+ # Count signed contracts
345
+ signed_count = len([c for c in all_contracts if c.state == ContractState.SIGNED])
346
+
347
+ return {
348
+ "total_contracts": len(all_contracts),
349
+ "by_state": state_counts,
350
+ "hipaa_compliant": hipaa_count,
351
+ "signed_contracts": signed_count,
352
+ "expired_contracts": len(registry.get_expired_contracts())
353
+ }
354
+
355
+ except Exception as e:
356
+ logger.error(f"Failed to get contract statistics: {e}")
357
+ return {}
src/fastmcp/contracts/registry.py ADDED
@@ -0,0 +1,391 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Contract registry for managing contract persistence and lifecycle."""
2
+
3
+ import json
4
+ from datetime import datetime
5
+ from typing import List, Optional
6
+ from uuid import UUID
7
+
8
+ from sqlmodel import Session, select
9
+
10
+ from fastmcp.utilities.logging import get_logger
11
+
12
+ from .contract import Contract, ContractState, Signature
13
+ from .crypto import ContractSigner, CryptoError
14
+
15
+ logger = get_logger(__name__)
16
+
17
+
18
+ class ContractRegistry:
19
+ """Registry for managing contract persistence and lifecycle operations."""
20
+
21
+ def __init__(self, session: Session):
22
+ """Initialize registry with database session.
23
+
24
+ Args:
25
+ session: SQLModel database session
26
+ """
27
+ self.session = session
28
+
29
+ def create_contract(self, contract_data: dict, created_by: str) -> Contract:
30
+ """Create a new contract.
31
+
32
+ Args:
33
+ contract_data: Contract data dictionary
34
+ created_by: ID of the creating party
35
+
36
+ Returns:
37
+ Created contract instance
38
+
39
+ Raises:
40
+ ValueError: If contract data is invalid
41
+ """
42
+ try:
43
+ # Create contract instance
44
+ contract = Contract(
45
+ title=contract_data["title"],
46
+ description=contract_data["description"],
47
+ created_by=created_by,
48
+ is_hipaa_compliant=contract_data.get("is_hipaa_compliant", False),
49
+ version=contract_data.get("version", "1.0.0")
50
+ )
51
+
52
+ # Set clauses
53
+ if "clauses" in contract_data:
54
+ from .contract import Clause
55
+ clauses = [Clause(**clause) for clause in contract_data["clauses"]]
56
+ contract.set_clauses(clauses)
57
+
58
+ # Set parties
59
+ if "parties" in contract_data:
60
+ contract.set_parties(contract_data["parties"])
61
+
62
+ # Set HIPAA entities
63
+ if "hipaa_entities" in contract_data and contract_data["hipaa_entities"]:
64
+ contract.set_hipaa_entities(contract_data["hipaa_entities"])
65
+
66
+ # Set metadata
67
+ if "metadata" in contract_data:
68
+ contract.set_metadata(contract_data["metadata"])
69
+
70
+ # Set expiration
71
+ if "expires_at" in contract_data and contract_data["expires_at"]:
72
+ contract.expires_at = contract_data["expires_at"]
73
+
74
+ # Save to database
75
+ self.session.add(contract)
76
+ self.session.commit()
77
+ self.session.refresh(contract)
78
+
79
+ logger.info(f"Created contract {contract.id} by {created_by}")
80
+ return contract
81
+
82
+ except Exception as e:
83
+ self.session.rollback()
84
+ logger.error(f"Failed to create contract: {e}")
85
+ raise ValueError(f"Contract creation failed: {e}")
86
+
87
+ def get_contract(self, contract_id: UUID) -> Optional[Contract]:
88
+ """Get a contract by ID.
89
+
90
+ Args:
91
+ contract_id: Contract UUID
92
+
93
+ Returns:
94
+ Contract instance or None if not found
95
+ """
96
+ try:
97
+ statement = select(Contract).where(Contract.id == contract_id)
98
+ return self.session.exec(statement).first()
99
+ except Exception as e:
100
+ logger.error(f"Failed to get contract {contract_id}: {e}")
101
+ return None
102
+
103
+ def list_contracts(self, state: Optional[ContractState] = None,
104
+ created_by: Optional[str] = None) -> List[Contract]:
105
+ """List contracts with optional filtering.
106
+
107
+ Args:
108
+ state: Optional state filter
109
+ created_by: Optional creator filter
110
+
111
+ Returns:
112
+ List of contract instances
113
+ """
114
+ try:
115
+ statement = select(Contract)
116
+
117
+ if state is not None:
118
+ statement = statement.where(Contract.state == state)
119
+
120
+ if created_by is not None:
121
+ statement = statement.where(Contract.created_by == created_by)
122
+
123
+ statement = statement.order_by(Contract.created_at.desc())
124
+ return list(self.session.exec(statement))
125
+
126
+ except Exception as e:
127
+ logger.error(f"Failed to list contracts: {e}")
128
+ return []
129
+
130
+ def update_contract_state(self, contract_id: UUID, new_state: ContractState,
131
+ updated_by: str, metadata: Optional[dict] = None) -> Optional[Contract]:
132
+ """Update contract state with lifecycle validation.
133
+
134
+ Args:
135
+ contract_id: Contract UUID
136
+ new_state: New state to transition to
137
+ updated_by: ID of the updating party
138
+ metadata: Optional metadata for the state change
139
+
140
+ Returns:
141
+ Updated contract instance or None if not found
142
+
143
+ Raises:
144
+ ValueError: If state transition is invalid
145
+ """
146
+ try:
147
+ contract = self.get_contract(contract_id)
148
+ if not contract:
149
+ return None
150
+
151
+ # Validate state transition
152
+ if not contract.can_transition_to(new_state):
153
+ raise ValueError(f"Invalid state transition from {contract.state} to {new_state}")
154
+
155
+ # Update state and timestamps
156
+ old_state = contract.state
157
+ contract.state = new_state
158
+ contract.last_modified = datetime.utcnow()
159
+
160
+ # Set state-specific timestamps
161
+ if new_state == ContractState.PROPOSED and contract.proposed_at is None:
162
+ contract.proposed_at = datetime.utcnow()
163
+ elif new_state == ContractState.SIGNED and contract.signed_at is None:
164
+ contract.signed_at = datetime.utcnow()
165
+ elif new_state == ContractState.REVOKED and contract.revoked_at is None:
166
+ contract.revoked_at = datetime.utcnow()
167
+
168
+ # Update metadata if provided
169
+ if metadata:
170
+ current_metadata = contract.get_metadata()
171
+ current_metadata.update(metadata)
172
+ current_metadata[f"state_change_{new_state}"] = {
173
+ "timestamp": datetime.utcnow().isoformat(),
174
+ "updated_by": updated_by,
175
+ "previous_state": old_state
176
+ }
177
+ contract.set_metadata(current_metadata)
178
+
179
+ # Save changes
180
+ self.session.add(contract)
181
+ self.session.commit()
182
+ self.session.refresh(contract)
183
+
184
+ logger.info(f"Updated contract {contract_id} state from {old_state} to {new_state} by {updated_by}")
185
+ return contract
186
+
187
+ except Exception as e:
188
+ self.session.rollback()
189
+ logger.error(f"Failed to update contract {contract_id} state: {e}")
190
+ raise
191
+
192
+ def add_signature(self, contract_id: UUID, signature_data: dict) -> Optional[Contract]:
193
+ """Add a signature to a contract.
194
+
195
+ Args:
196
+ contract_id: Contract UUID
197
+ signature_data: Signature data dictionary
198
+
199
+ Returns:
200
+ Updated contract instance or None if not found
201
+
202
+ Raises:
203
+ ValueError: If signature is invalid or contract not in signable state
204
+ """
205
+ try:
206
+ contract = self.get_contract(contract_id)
207
+ if not contract:
208
+ return None
209
+
210
+ # Validate contract state
211
+ if contract.state not in [ContractState.PROPOSED, ContractState.SIGNED]:
212
+ raise ValueError(f"Cannot sign contract in state {contract.state}")
213
+
214
+ # Create signature
215
+ from .contract import Signature
216
+ signature = Signature(**signature_data)
217
+
218
+ # Verify signature
219
+ if not self._verify_signature(contract, signature):
220
+ raise ValueError("Invalid signature")
221
+
222
+ # Add signature
223
+ signatures = contract.get_signatures()
224
+ signatures.append(signature)
225
+ contract.set_signatures(signatures)
226
+
227
+ # Update state if fully signed
228
+ if contract.is_fully_signed() and contract.state == ContractState.PROPOSED:
229
+ # Use the proper state transition method
230
+ contract = self.update_contract_state(contract_id, ContractState.SIGNED, signature.signer_id)
231
+
232
+ contract.last_modified = datetime.utcnow()
233
+
234
+ # Save changes
235
+ self.session.add(contract)
236
+ self.session.commit()
237
+ self.session.refresh(contract)
238
+
239
+ logger.info(f"Added signature to contract {contract_id} by {signature.signer_id}")
240
+ return contract
241
+
242
+ except Exception as e:
243
+ self.session.rollback()
244
+ logger.error(f"Failed to add signature to contract {contract_id}: {e}")
245
+ raise
246
+
247
+ def _verify_signature(self, contract: Contract, signature: Signature) -> bool:
248
+ """Verify a contract signature.
249
+
250
+ Args:
251
+ contract: Contract instance
252
+ signature: Signature to verify
253
+
254
+ Returns:
255
+ True if signature is valid, False otherwise
256
+ """
257
+ try:
258
+ from .crypto import verify_signature
259
+
260
+ # Create signing message
261
+ signing_message = f"{contract.id}:{contract.get_content_hash()}:{signature.signer_id}:{signature.signer_type}"
262
+
263
+ # Verify signature
264
+ return verify_signature(
265
+ signature.public_key,
266
+ signing_message,
267
+ signature.signature
268
+ )
269
+
270
+ except Exception as e:
271
+ logger.error(f"Signature verification failed: {e}")
272
+ return False
273
+
274
+ def revoke_contract(self, contract_id: UUID, reason: str, revoked_by: str,
275
+ metadata: Optional[dict] = None) -> Optional[Contract]:
276
+ """Revoke a contract.
277
+
278
+ Args:
279
+ contract_id: Contract UUID
280
+ reason: Reason for revocation
281
+ revoked_by: ID of the revoking party
282
+ metadata: Optional revocation metadata
283
+
284
+ Returns:
285
+ Updated contract instance or None if not found
286
+
287
+ Raises:
288
+ ValueError: If contract cannot be revoked
289
+ """
290
+ try:
291
+ contract = self.get_contract(contract_id)
292
+ if not contract:
293
+ return None
294
+
295
+ # Validate revocation
296
+ if contract.state == ContractState.REVOKED:
297
+ raise ValueError("Contract is already revoked")
298
+
299
+ if contract.state == ContractState.EXPIRED:
300
+ raise ValueError("Cannot revoke expired contract")
301
+
302
+ # Update metadata with revocation info
303
+ current_metadata = contract.get_metadata()
304
+ current_metadata["revocation"] = {
305
+ "reason": reason,
306
+ "revoked_by": revoked_by,
307
+ "timestamp": datetime.utcnow().isoformat()
308
+ }
309
+ if metadata:
310
+ current_metadata["revocation"].update(metadata)
311
+
312
+ contract.set_metadata(current_metadata)
313
+
314
+ # Update state
315
+ return self.update_contract_state(contract_id, ContractState.REVOKED, revoked_by)
316
+
317
+ except Exception as e:
318
+ logger.error(f"Failed to revoke contract {contract_id}: {e}")
319
+ raise
320
+
321
+ def get_contracts_by_party(self, party_id: str) -> List[Contract]:
322
+ """Get contracts involving a specific party.
323
+
324
+ Args:
325
+ party_id: Party ID to search for
326
+
327
+ Returns:
328
+ List of contracts involving the party
329
+ """
330
+ try:
331
+ statement = select(Contract)
332
+ contracts = list(self.session.exec(statement))
333
+
334
+ # Filter contracts that involve the party
335
+ party_contracts = []
336
+ for contract in contracts:
337
+ parties = contract.get_parties()
338
+ if any(party.get("id") == party_id for party in parties):
339
+ party_contracts.append(contract)
340
+
341
+ return party_contracts
342
+
343
+ except Exception as e:
344
+ logger.error(f"Failed to get contracts for party {party_id}: {e}")
345
+ return []
346
+
347
+ def get_expired_contracts(self) -> List[Contract]:
348
+ """Get contracts that have expired.
349
+
350
+ Returns:
351
+ List of expired contracts
352
+ """
353
+ try:
354
+ now = datetime.utcnow()
355
+ statement = select(Contract).where(
356
+ Contract.expires_at.isnot(None),
357
+ Contract.expires_at < now,
358
+ Contract.state.notin_([ContractState.EXPIRED, ContractState.REVOKED])
359
+ )
360
+ return list(self.session.exec(statement))
361
+
362
+ except Exception as e:
363
+ logger.error(f"Failed to get expired contracts: {e}")
364
+ return []
365
+
366
+ def mark_contracts_expired(self) -> int:
367
+ """Mark expired contracts as expired.
368
+
369
+ Returns:
370
+ Number of contracts marked as expired
371
+ """
372
+ try:
373
+ expired_contracts = self.get_expired_contracts()
374
+ count = 0
375
+
376
+ for contract in expired_contracts:
377
+ contract.state = ContractState.EXPIRED
378
+ contract.last_modified = datetime.utcnow()
379
+ self.session.add(contract)
380
+ count += 1
381
+
382
+ if count > 0:
383
+ self.session.commit()
384
+ logger.info(f"Marked {count} contracts as expired")
385
+
386
+ return count
387
+
388
+ except Exception as e:
389
+ self.session.rollback()
390
+ logger.error(f"Failed to mark contracts as expired: {e}")
391
+ return 0
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/contract_routes.py ADDED
@@ -0,0 +1,425 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Contract 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.contracts import ContractEngine, ContractState
11
+ from fastmcp.contracts.contract import (
12
+ ContractCreateRequest, ContractProposeRequest, ContractSignRequest,
13
+ ContractRevokeRequest, ContractResponse
14
+ )
15
+ from fastmcp.utilities.logging import get_logger
16
+
17
+ logger = get_logger(__name__)
18
+
19
+
20
+ async def create_contract_endpoint(request: Request) -> JSONResponse:
21
+ """HTTP endpoint for creating contracts.
22
+
23
+ Expected JSON body:
24
+ {
25
+ "title": "Contract Title",
26
+ "description": "Contract Description",
27
+ "clauses": [...],
28
+ "parties": [...],
29
+ "is_hipaa_compliant": false,
30
+ "expires_at": "2024-12-31T23:59:59Z"
31
+ }
32
+ """
33
+ try:
34
+ # Parse request body
35
+ body = await request.json()
36
+
37
+ # Get contract engine from request state
38
+ contract_engine: ContractEngine = request.app.state.contract_engine
39
+
40
+ # Get creator from headers or body
41
+ created_by = request.headers.get("X-User-ID") or body.get("created_by", "anonymous")
42
+
43
+ # Create contract request
44
+ contract_request = ContractCreateRequest(**body)
45
+
46
+ # Create contract
47
+ contract = await contract_engine.create_contract(contract_request, created_by)
48
+
49
+ # Return contract response
50
+ response = ContractResponse.from_contract(contract)
51
+ return JSONResponse(
52
+ status_code=201,
53
+ content=response.model_dump()
54
+ )
55
+
56
+ except Exception as e:
57
+ logger.error(f"Contract creation error: {e}")
58
+ return JSONResponse(
59
+ status_code=400,
60
+ content={
61
+ "error": "Contract creation failed",
62
+ "reason": str(e)
63
+ }
64
+ )
65
+
66
+
67
+ async def get_contract_endpoint(request: Request) -> JSONResponse:
68
+ """HTTP endpoint for getting a contract by ID."""
69
+ try:
70
+ # Get contract ID from path
71
+ contract_id = UUID(request.path_params["id"])
72
+
73
+ # Get contract engine from request state
74
+ contract_engine: ContractEngine = request.app.state.contract_engine
75
+
76
+ # Get contract
77
+ contract = await contract_engine.get_contract(contract_id)
78
+
79
+ if not contract:
80
+ return JSONResponse(
81
+ status_code=404,
82
+ content={
83
+ "error": "Contract not found",
84
+ "contract_id": str(contract_id)
85
+ }
86
+ )
87
+
88
+ # Return contract response
89
+ response = ContractResponse.from_contract(contract)
90
+ return JSONResponse(
91
+ status_code=200,
92
+ content=response.model_dump()
93
+ )
94
+
95
+ except ValueError as e:
96
+ return JSONResponse(
97
+ status_code=400,
98
+ content={
99
+ "error": "Invalid contract ID",
100
+ "reason": str(e)
101
+ }
102
+ )
103
+ except Exception as e:
104
+ logger.error(f"Contract retrieval error: {e}")
105
+ return JSONResponse(
106
+ status_code=500,
107
+ content={
108
+ "error": "Contract retrieval failed",
109
+ "reason": str(e)
110
+ }
111
+ )
112
+
113
+
114
+ async def list_contracts_endpoint(request: Request) -> JSONResponse:
115
+ """HTTP endpoint for listing contracts."""
116
+ try:
117
+ # Get query parameters
118
+ state = request.query_params.get("state")
119
+ created_by = request.query_params.get("created_by")
120
+
121
+ # Get contract engine from request state
122
+ contract_engine: ContractEngine = request.app.state.contract_engine
123
+
124
+ # Parse state if provided
125
+ contract_state = None
126
+ if state:
127
+ try:
128
+ contract_state = ContractState(state)
129
+ except ValueError:
130
+ return JSONResponse(
131
+ status_code=400,
132
+ content={
133
+ "error": "Invalid state",
134
+ "valid_states": [s.value for s in ContractState]
135
+ }
136
+ )
137
+
138
+ # List contracts
139
+ contracts = await contract_engine.list_contracts(contract_state, created_by)
140
+
141
+ # Convert to response format
142
+ responses = [ContractResponse.from_contract(contract).model_dump() for contract in contracts]
143
+
144
+ return JSONResponse(
145
+ status_code=200,
146
+ content={
147
+ "contracts": responses,
148
+ "count": len(responses)
149
+ }
150
+ )
151
+
152
+ except Exception as e:
153
+ logger.error(f"Contract listing error: {e}")
154
+ return JSONResponse(
155
+ status_code=500,
156
+ content={
157
+ "error": "Contract listing failed",
158
+ "reason": str(e)
159
+ }
160
+ )
161
+
162
+
163
+ async def propose_contract_endpoint(request: Request) -> JSONResponse:
164
+ """HTTP endpoint for proposing contracts.
165
+
166
+ Expected JSON body:
167
+ {
168
+ "proposed_to": ["party1", "party2"],
169
+ "message": "Please review and sign this contract"
170
+ }
171
+ """
172
+ try:
173
+ # Get contract ID from path
174
+ contract_id = UUID(request.path_params["id"])
175
+
176
+ # Parse request body
177
+ body = await request.json()
178
+
179
+ # Get contract engine from request state
180
+ contract_engine: ContractEngine = request.app.state.contract_engine
181
+
182
+ # Get proposer from headers or body
183
+ proposed_by = request.headers.get("X-User-ID") or body.get("proposed_by", "anonymous")
184
+
185
+ # Create proposal request
186
+ proposal_request = ContractProposeRequest(**body)
187
+
188
+ # Propose contract
189
+ contract = await contract_engine.propose_contract(contract_id, proposal_request, proposed_by)
190
+
191
+ if not contract:
192
+ return JSONResponse(
193
+ status_code=404,
194
+ content={
195
+ "error": "Contract not found",
196
+ "contract_id": str(contract_id)
197
+ }
198
+ )
199
+
200
+ # Return contract response
201
+ response = ContractResponse.from_contract(contract)
202
+ return JSONResponse(
203
+ status_code=200,
204
+ content=response.model_dump()
205
+ )
206
+
207
+ except ValueError as e:
208
+ return JSONResponse(
209
+ status_code=400,
210
+ content={
211
+ "error": "Invalid request",
212
+ "reason": str(e)
213
+ }
214
+ )
215
+ except Exception as e:
216
+ logger.error(f"Contract proposal error: {e}")
217
+ return JSONResponse(
218
+ status_code=500,
219
+ content={
220
+ "error": "Contract proposal failed",
221
+ "reason": str(e)
222
+ }
223
+ )
224
+
225
+
226
+ async def sign_contract_endpoint(request: Request) -> JSONResponse:
227
+ """HTTP endpoint for signing contracts.
228
+
229
+ Expected JSON body:
230
+ {
231
+ "signer_id": "party1",
232
+ "signer_type": "provider",
233
+ "public_key": "base64_public_key",
234
+ "signature": "base64_signature"
235
+ }
236
+ """
237
+ try:
238
+ # Get contract ID from path
239
+ contract_id = UUID(request.path_params["id"])
240
+
241
+ # Parse request body
242
+ body = await request.json()
243
+
244
+ # Get contract engine from request state
245
+ contract_engine: ContractEngine = request.app.state.contract_engine
246
+
247
+ # Create signing request
248
+ sign_request = ContractSignRequest(**body)
249
+
250
+ # Sign contract
251
+ contract = await contract_engine.sign_contract(contract_id, sign_request)
252
+
253
+ if not contract:
254
+ return JSONResponse(
255
+ status_code=404,
256
+ content={
257
+ "error": "Contract not found",
258
+ "contract_id": str(contract_id)
259
+ }
260
+ )
261
+
262
+ # Return contract response
263
+ response = ContractResponse.from_contract(contract)
264
+ return JSONResponse(
265
+ status_code=200,
266
+ content=response.model_dump()
267
+ )
268
+
269
+ except ValueError as e:
270
+ return JSONResponse(
271
+ status_code=400,
272
+ content={
273
+ "error": "Invalid request",
274
+ "reason": str(e)
275
+ }
276
+ )
277
+ except Exception as e:
278
+ logger.error(f"Contract signing error: {e}")
279
+ return JSONResponse(
280
+ status_code=500,
281
+ content={
282
+ "error": "Contract signing failed",
283
+ "reason": str(e)
284
+ }
285
+ )
286
+
287
+
288
+ async def revoke_contract_endpoint(request: Request) -> JSONResponse:
289
+ """HTTP endpoint for revoking contracts.
290
+
291
+ Expected JSON body:
292
+ {
293
+ "reason": "Contract terms violated",
294
+ "revoked_by": "party1"
295
+ }
296
+ """
297
+ try:
298
+ # Get contract ID from path
299
+ contract_id = UUID(request.path_params["id"])
300
+
301
+ # Parse request body
302
+ body = await request.json()
303
+
304
+ # Get contract engine from request state
305
+ contract_engine: ContractEngine = request.app.state.contract_engine
306
+
307
+ # Create revocation request
308
+ revoke_request = ContractRevokeRequest(**body)
309
+
310
+ # Revoke contract
311
+ contract = await contract_engine.revoke_contract(contract_id, revoke_request)
312
+
313
+ if not contract:
314
+ return JSONResponse(
315
+ status_code=404,
316
+ content={
317
+ "error": "Contract not found",
318
+ "contract_id": str(contract_id)
319
+ }
320
+ )
321
+
322
+ # Return contract response
323
+ response = ContractResponse.from_contract(contract)
324
+ return JSONResponse(
325
+ status_code=200,
326
+ content=response.model_dump()
327
+ )
328
+
329
+ except ValueError as e:
330
+ return JSONResponse(
331
+ status_code=400,
332
+ content={
333
+ "error": "Invalid request",
334
+ "reason": str(e)
335
+ }
336
+ )
337
+ except Exception as e:
338
+ logger.error(f"Contract revocation error: {e}")
339
+ return JSONResponse(
340
+ status_code=500,
341
+ content={
342
+ "error": "Contract revocation failed",
343
+ "reason": str(e)
344
+ }
345
+ )
346
+
347
+
348
+ async def get_contract_statistics_endpoint(request: Request) -> JSONResponse:
349
+ """HTTP endpoint for getting contract statistics."""
350
+ try:
351
+ # Get contract engine from request state
352
+ contract_engine: ContractEngine = request.app.state.contract_engine
353
+
354
+ # Get statistics
355
+ stats = await contract_engine.get_contract_statistics()
356
+
357
+ return JSONResponse(
358
+ status_code=200,
359
+ content=stats
360
+ )
361
+
362
+ except Exception as e:
363
+ logger.error(f"Contract statistics error: {e}")
364
+ return JSONResponse(
365
+ status_code=500,
366
+ content={
367
+ "error": "Contract statistics failed",
368
+ "reason": str(e)
369
+ }
370
+ )
371
+
372
+
373
+ def create_contract_routes(contract_engine: ContractEngine) -> List[Route]:
374
+ """Create contract management routes.
375
+
376
+ Args:
377
+ contract_engine: The contract engine instance
378
+
379
+ Returns:
380
+ List of Starlette Route objects for contract management
381
+ """
382
+ def endpoint_with_engine(endpoint_func):
383
+ async def wrapper(request: Request) -> JSONResponse:
384
+ # Store contract engine in app state for access in endpoint
385
+ request.app.state.contract_engine = contract_engine
386
+ return await endpoint_func(request)
387
+ return wrapper
388
+
389
+ return [
390
+ Route(
391
+ path="/contracts",
392
+ endpoint=endpoint_with_engine(create_contract_endpoint),
393
+ methods=["POST"]
394
+ ),
395
+ Route(
396
+ path="/contracts",
397
+ endpoint=endpoint_with_engine(list_contracts_endpoint),
398
+ methods=["GET"]
399
+ ),
400
+ Route(
401
+ path="/contracts/statistics",
402
+ endpoint=endpoint_with_engine(get_contract_statistics_endpoint),
403
+ methods=["GET"]
404
+ ),
405
+ Route(
406
+ path="/contracts/{id}",
407
+ endpoint=endpoint_with_engine(get_contract_endpoint),
408
+ methods=["GET"]
409
+ ),
410
+ Route(
411
+ path="/contracts/{id}/propose",
412
+ endpoint=endpoint_with_engine(propose_contract_endpoint),
413
+ methods=["POST"]
414
+ ),
415
+ Route(
416
+ path="/contracts/{id}/sign",
417
+ endpoint=endpoint_with_engine(sign_contract_endpoint),
418
+ methods=["POST"]
419
+ ),
420
+ Route(
421
+ path="/contracts/{id}/revoke",
422
+ endpoint=endpoint_with_engine(revoke_contract_endpoint),
423
+ methods=["POST"]
424
+ )
425
+ ]
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
@@ -65,6 +65,8 @@ 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.reflexive import ReflexiveEngine
 
 
68
  from fastmcp.utilities.cli import log_server_banner
69
  from fastmcp.utilities.components import FastMCPComponent
70
  from fastmcp.utilities.logging import get_logger
@@ -178,6 +180,8 @@ class FastMCP(Generic[LifespanResultT]):
178
  self._mounted_servers: list[MountedServer] = []
179
  self._policy_engine: Optional[PolicyEngine] = None
180
  self._reflexive_engine: Optional[ReflexiveEngine] = None
 
 
181
  self._tool_manager = ToolManager(
182
  duplicate_behavior=on_duplicate_tools,
183
  mask_error_details=mask_error_details,
@@ -516,13 +520,26 @@ class FastMCP(Generic[LifespanResultT]):
516
  policy_route = create_policy_evaluate_route(self._policy_engine)
517
  routes.append(policy_route)
518
 
519
-
520
  # Add reflexive core endpoints if reflexive engine is configured
521
  if self._reflexive_engine is not None:
522
  from fastmcp.server.reflexive_routes import create_reflexive_routes
523
 
524
  reflexive_routes = create_reflexive_routes(self._reflexive_engine)
525
  routes.extend(reflexive_routes)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
526
 
527
  # Recursively get routes from mounted servers
528
  for mounted_server in self._mounted_servers:
@@ -554,6 +571,56 @@ class FastMCP(Generic[LifespanResultT]):
554
  The policy engine instance, or None if not enabled
555
  """
556
  return self._policy_engine
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
557
 
558
 
559
  def enable_reflexive_core(self, reflexive_engine: Optional[ReflexiveEngine] = None) -> ReflexiveEngine:
 
65
  from fastmcp.tools.tool_transform import ToolTransformConfig
66
  from fastmcp.policy import PolicyEngine
67
  from fastmcp.reflexive import ReflexiveEngine
68
+ from fastmcp.ledger import ProvenanceLedger
69
+ from fastmcp.contracts import ContractEngine
70
  from fastmcp.utilities.cli import log_server_banner
71
  from fastmcp.utilities.components import FastMCPComponent
72
  from fastmcp.utilities.logging import get_logger
 
180
  self._mounted_servers: list[MountedServer] = []
181
  self._policy_engine: Optional[PolicyEngine] = None
182
  self._reflexive_engine: Optional[ReflexiveEngine] = None
183
+ self._ledger: Optional[ProvenanceLedger] = None
184
+ self._contract_engine: Optional[ContractEngine] = None
185
  self._tool_manager = ToolManager(
186
  duplicate_behavior=on_duplicate_tools,
187
  mask_error_details=mask_error_details,
 
520
  policy_route = create_policy_evaluate_route(self._policy_engine)
521
  routes.append(policy_route)
522
 
 
523
  # Add reflexive core endpoints if reflexive engine is configured
524
  if self._reflexive_engine is not None:
525
  from fastmcp.server.reflexive_routes import create_reflexive_routes
526
 
527
  reflexive_routes = create_reflexive_routes(self._reflexive_engine)
528
  routes.extend(reflexive_routes)
529
+
530
+ # Add ledger management endpoints if ledger is configured
531
+ if self._ledger is not None:
532
+ from fastmcp.server.ledger_routes import create_ledger_routes
533
+
534
+ ledger_routes = create_ledger_routes(self._ledger)
535
+ routes.extend(ledger_routes)
536
+
537
+ # Add contract management endpoints if contract engine is configured
538
+ if self._contract_engine is not None:
539
+ from fastmcp.server.contract_routes import create_contract_routes
540
+
541
+ contract_routes = create_contract_routes(self._contract_engine)
542
+ routes.extend(contract_routes)
543
 
544
  # Recursively get routes from mounted servers
545
  for mounted_server in self._mounted_servers:
 
571
  The policy engine instance, or None if not enabled
572
  """
573
  return self._policy_engine
574
+
575
+ def enable_ledger(self, ledger: Optional[ProvenanceLedger] = None, database_url: str = "sqlite:///ledger.db") -> ProvenanceLedger:
576
+ """Enable the provenance ledger for this server.
577
+
578
+ Args:
579
+ ledger: Optional ledger instance. If None, creates a new one.
580
+ database_url: Database URL for ledger persistence
581
+
582
+ Returns:
583
+ The ledger instance
584
+ """
585
+ if ledger is None:
586
+ ledger = ProvenanceLedger(database_url)
587
+
588
+ self._ledger = ledger
589
+ logger.info("Provenance ledger enabled for server")
590
+ return ledger
591
+
592
+ def get_ledger(self) -> Optional[ProvenanceLedger]:
593
+ """Get the provenance ledger instance.
594
+
595
+ Returns:
596
+ The ledger instance, or None if not enabled
597
+ """
598
+ return self._ledger
599
+
600
+ def enable_contract_engine(self, contract_engine: Optional[ContractEngine] = None, database_url: str = "sqlite:///contracts.db") -> ContractEngine:
601
+ """Enable the contract engine for this server.
602
+
603
+ Args:
604
+ contract_engine: Optional contract engine instance. If None, creates a new one.
605
+ database_url: Database URL for contract persistence
606
+
607
+ Returns:
608
+ The contract engine instance
609
+ """
610
+ if contract_engine is None:
611
+ contract_engine = ContractEngine(database_url)
612
+
613
+ self._contract_engine = contract_engine
614
+ logger.info("Contract engine enabled for server")
615
+ return contract_engine
616
+
617
+ def get_contract_engine(self) -> Optional[ContractEngine]:
618
+ """Get the contract engine instance.
619
+
620
+ Returns:
621
+ The contract engine instance, or None if not enabled
622
+ """
623
+ return self._contract_engine
624
 
625
 
626
  def enable_reflexive_core(self, reflexive_engine: Optional[ReflexiveEngine] = None) -> ReflexiveEngine:
tests/contracts/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Tests for the contract management system."""
tests/contracts/test_contract_engine.py ADDED
@@ -0,0 +1,408 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the contract engine."""
2
+
3
+ import pytest
4
+ from datetime import datetime, timedelta
5
+ from uuid import UUID
6
+
7
+ from fastmcp.contracts import ContractEngine, ContractState
8
+ from fastmcp.contracts.contract import (
9
+ Contract, Clause, Signature, ContractCreateRequest, ContractProposeRequest,
10
+ ContractSignRequest, ContractRevokeRequest
11
+ )
12
+ from fastmcp.contracts.crypto import Ed25519Signer, generate_key_pair
13
+
14
+
15
+ class TestContractEngine:
16
+ """Test the contract engine functionality."""
17
+
18
+ @pytest.fixture
19
+ def contract_engine(self):
20
+ """Create a contract engine for testing."""
21
+ return ContractEngine("sqlite:///:memory:")
22
+
23
+ @pytest.fixture
24
+ def sample_clauses(self):
25
+ """Create sample clauses for testing."""
26
+ return [
27
+ Clause(
28
+ title="Data Handling",
29
+ content="All data must be handled in accordance with HIPAA regulations.",
30
+ type="hipaa"
31
+ ),
32
+ Clause(
33
+ title="Access Control",
34
+ content="Only authorized personnel may access patient data.",
35
+ type="security"
36
+ )
37
+ ]
38
+
39
+ @pytest.fixture
40
+ def sample_parties(self):
41
+ """Create sample parties for testing."""
42
+ return [
43
+ {
44
+ "id": "provider1",
45
+ "name": "Healthcare Provider",
46
+ "type": "provider",
47
+ "email": "provider@example.com"
48
+ },
49
+ {
50
+ "id": "patient1",
51
+ "name": "John Doe",
52
+ "type": "patient",
53
+ "email": "patient@example.com"
54
+ }
55
+ ]
56
+
57
+ @pytest.fixture
58
+ def sample_contract_request(self, sample_clauses, sample_parties):
59
+ """Create a sample contract creation request."""
60
+ return ContractCreateRequest(
61
+ title="HIPAA Data Sharing Agreement",
62
+ description="Agreement for sharing patient data between healthcare providers",
63
+ clauses=sample_clauses,
64
+ parties=sample_parties,
65
+ is_hipaa_compliant=True,
66
+ expires_at=datetime.utcnow() + timedelta(days=365)
67
+ )
68
+
69
+ def test_contract_engine_initialization(self, contract_engine):
70
+ """Test contract engine initialization."""
71
+ assert contract_engine.engine is not None
72
+ assert contract_engine.get_registry() is not None
73
+
74
+ @pytest.mark.asyncio
75
+ async def test_create_contract(self, contract_engine, sample_contract_request):
76
+ """Test contract creation."""
77
+ created_by = "admin"
78
+
79
+ contract = await contract_engine.create_contract(sample_contract_request, created_by)
80
+
81
+ assert contract is not None
82
+ assert contract.title == sample_contract_request.title
83
+ assert contract.description == sample_contract_request.description
84
+ assert contract.state == ContractState.DRAFT
85
+ assert contract.created_by == created_by
86
+ assert contract.is_hipaa_compliant is True
87
+ assert len(contract.get_clauses()) == 2
88
+ assert len(contract.get_parties()) == 2
89
+
90
+ @pytest.mark.asyncio
91
+ async def test_get_contract(self, contract_engine, sample_contract_request):
92
+ """Test getting a contract by ID."""
93
+ created_by = "admin"
94
+ contract = await contract_engine.create_contract(sample_contract_request, created_by)
95
+
96
+ retrieved_contract = await contract_engine.get_contract(contract.id)
97
+
98
+ assert retrieved_contract is not None
99
+ assert retrieved_contract.id == contract.id
100
+ assert retrieved_contract.title == contract.title
101
+
102
+ @pytest.mark.asyncio
103
+ async def test_get_contract_not_found(self, contract_engine):
104
+ """Test getting a non-existent contract."""
105
+ fake_id = UUID("12345678-1234-1234-1234-123456789012")
106
+ contract = await contract_engine.get_contract(fake_id)
107
+
108
+ assert contract is None
109
+
110
+ @pytest.mark.asyncio
111
+ async def test_list_contracts(self, contract_engine, sample_contract_request):
112
+ """Test listing contracts."""
113
+ created_by = "admin"
114
+
115
+ # Create multiple contracts
116
+ contract1 = await contract_engine.create_contract(sample_contract_request, created_by)
117
+ contract2 = await contract_engine.create_contract(sample_contract_request, created_by)
118
+
119
+ # List all contracts
120
+ all_contracts = await contract_engine.list_contracts()
121
+ assert len(all_contracts) == 2
122
+
123
+ # List contracts by state
124
+ draft_contracts = await contract_engine.list_contracts(state=ContractState.DRAFT)
125
+ assert len(draft_contracts) == 2
126
+
127
+ # List contracts by creator
128
+ admin_contracts = await contract_engine.list_contracts(created_by=created_by)
129
+ assert len(admin_contracts) == 2
130
+
131
+ @pytest.mark.asyncio
132
+ async def test_propose_contract(self, contract_engine, sample_contract_request):
133
+ """Test proposing a contract."""
134
+ created_by = "admin"
135
+ contract = await contract_engine.create_contract(sample_contract_request, created_by)
136
+
137
+ proposal_request = ContractProposeRequest(
138
+ proposed_to=["provider1", "patient1"],
139
+ message="Please review and sign this contract"
140
+ )
141
+
142
+ proposed_contract = await contract_engine.propose_contract(
143
+ contract.id, proposal_request, created_by
144
+ )
145
+
146
+ assert proposed_contract is not None
147
+ assert proposed_contract.state == ContractState.PROPOSED
148
+ assert proposed_contract.proposed_at is not None
149
+
150
+ @pytest.mark.asyncio
151
+ async def test_propose_contract_invalid_state(self, contract_engine, sample_contract_request):
152
+ """Test proposing a contract in invalid state."""
153
+ created_by = "admin"
154
+ contract = await contract_engine.create_contract(sample_contract_request, created_by)
155
+
156
+ # First propose the contract
157
+ proposal_request = ContractProposeRequest(proposed_to=["provider1"])
158
+ await contract_engine.propose_contract(contract.id, proposal_request, created_by)
159
+
160
+ # Try to propose again (should fail)
161
+ with pytest.raises(ValueError, match="Cannot propose contract in state ContractState.PROPOSED"):
162
+ await contract_engine.propose_contract(contract.id, proposal_request, created_by)
163
+
164
+ @pytest.mark.asyncio
165
+ async def test_sign_contract(self, contract_engine, sample_contract_request):
166
+ """Test signing a contract."""
167
+ created_by = "admin"
168
+ contract = await contract_engine.create_contract(sample_contract_request, created_by)
169
+
170
+ # Propose the contract
171
+ proposal_request = ContractProposeRequest(proposed_to=["provider1"])
172
+ await contract_engine.propose_contract(contract.id, proposal_request, created_by)
173
+
174
+ # Generate key pair for signing
175
+ public_key, private_key = generate_key_pair()
176
+ signer = Ed25519Signer.from_private_key_b64(private_key)
177
+
178
+ # Create signing message
179
+ signing_message = f"{contract.id}:{contract.get_content_hash()}:provider1:provider"
180
+ signature = signer.sign(signing_message)
181
+
182
+ # Sign the contract
183
+ sign_request = ContractSignRequest(
184
+ signer_id="provider1",
185
+ signer_type="provider",
186
+ public_key=public_key,
187
+ signature=signature
188
+ )
189
+
190
+ signed_contract = await contract_engine.sign_contract(contract.id, sign_request)
191
+
192
+ assert signed_contract is not None
193
+ assert len(signed_contract.get_signatures()) == 1
194
+ assert signed_contract.get_signatures()[0].signer_id == "provider1"
195
+
196
+ @pytest.mark.asyncio
197
+ async def test_sign_contract_invalid_signature(self, contract_engine, sample_contract_request):
198
+ """Test signing a contract with invalid signature."""
199
+ created_by = "admin"
200
+ contract = await contract_engine.create_contract(sample_contract_request, created_by)
201
+
202
+ # Propose the contract
203
+ proposal_request = ContractProposeRequest(proposed_to=["provider1"])
204
+ await contract_engine.propose_contract(contract.id, proposal_request, created_by)
205
+
206
+ # Generate key pair for signing
207
+ public_key, private_key = generate_key_pair()
208
+ signer = Ed25519Signer.from_private_key_b64(private_key)
209
+
210
+ # Create invalid signing message (wrong content hash)
211
+ invalid_signing_message = f"{contract.id}:invalid_hash:provider1:provider"
212
+ signature = signer.sign(invalid_signing_message)
213
+
214
+ # Try to sign with invalid signature
215
+ sign_request = ContractSignRequest(
216
+ signer_id="provider1",
217
+ signer_type="provider",
218
+ public_key=public_key,
219
+ signature=signature
220
+ )
221
+
222
+ with pytest.raises(ValueError, match="Invalid signature"):
223
+ await contract_engine.sign_contract(contract.id, sign_request)
224
+
225
+ @pytest.mark.asyncio
226
+ async def test_revoke_contract(self, contract_engine, sample_contract_request):
227
+ """Test revoking a contract."""
228
+ created_by = "admin"
229
+ contract = await contract_engine.create_contract(sample_contract_request, created_by)
230
+
231
+ revoke_request = ContractRevokeRequest(
232
+ reason="Contract terms violated",
233
+ revoked_by="admin"
234
+ )
235
+
236
+ revoked_contract = await contract_engine.revoke_contract(contract.id, revoke_request)
237
+
238
+ assert revoked_contract is not None
239
+ assert revoked_contract.state == ContractState.REVOKED
240
+ assert revoked_contract.revoked_at is not None
241
+
242
+ @pytest.mark.asyncio
243
+ async def test_revoke_contract_already_revoked(self, contract_engine, sample_contract_request):
244
+ """Test revoking an already revoked contract."""
245
+ created_by = "admin"
246
+ contract = await contract_engine.create_contract(sample_contract_request, created_by)
247
+
248
+ # First revoke the contract
249
+ revoke_request = ContractRevokeRequest(
250
+ reason="Contract terms violated",
251
+ revoked_by="admin"
252
+ )
253
+ await contract_engine.revoke_contract(contract.id, revoke_request)
254
+
255
+ # Try to revoke again (should fail)
256
+ with pytest.raises(ValueError, match="Contract is already revoked"):
257
+ await contract_engine.revoke_contract(contract.id, revoke_request)
258
+
259
+ @pytest.mark.asyncio
260
+ async def test_get_contracts_by_party(self, contract_engine, sample_contract_request):
261
+ """Test getting contracts by party."""
262
+ created_by = "admin"
263
+ contract = await contract_engine.create_contract(sample_contract_request, created_by)
264
+
265
+ # Get contracts for provider1
266
+ provider_contracts = await contract_engine.get_contracts_by_party("provider1")
267
+ assert len(provider_contracts) == 1
268
+ assert provider_contracts[0].id == contract.id
269
+
270
+ # Get contracts for non-existent party
271
+ empty_contracts = await contract_engine.get_contracts_by_party("nonexistent")
272
+ assert len(empty_contracts) == 0
273
+
274
+ @pytest.mark.asyncio
275
+ async def test_cleanup_expired_contracts(self, contract_engine, sample_contract_request):
276
+ """Test cleanup of expired contracts."""
277
+ created_by = "admin"
278
+
279
+ # Create contract with past expiration
280
+ past_expiration = datetime.utcnow() - timedelta(days=1)
281
+ sample_contract_request.expires_at = past_expiration
282
+
283
+ contract = await contract_engine.create_contract(sample_contract_request, created_by)
284
+
285
+ # Cleanup expired contracts
286
+ count = await contract_engine.cleanup_expired_contracts()
287
+
288
+ assert count == 1
289
+
290
+ # Verify contract is marked as expired
291
+ expired_contract = await contract_engine.get_contract(contract.id)
292
+ assert expired_contract.state == ContractState.EXPIRED
293
+
294
+ @pytest.mark.asyncio
295
+ async def test_get_contract_statistics(self, contract_engine, sample_contract_request):
296
+ """Test getting contract statistics."""
297
+ created_by = "admin"
298
+
299
+ # Create multiple contracts
300
+ contract1 = await contract_engine.create_contract(sample_contract_request, created_by)
301
+ contract2 = await contract_engine.create_contract(sample_contract_request, created_by)
302
+
303
+ # Propose one contract
304
+ proposal_request = ContractProposeRequest(proposed_to=["provider1"])
305
+ await contract_engine.propose_contract(contract1.id, proposal_request, created_by)
306
+
307
+ # Get statistics
308
+ stats = await contract_engine.get_contract_statistics()
309
+
310
+ assert stats["total_contracts"] == 2
311
+ assert stats["by_state"]["draft"] == 1
312
+ assert stats["by_state"]["proposed"] == 1
313
+ assert stats["hipaa_compliant"] == 2
314
+ assert stats["signed_contracts"] == 0
315
+
316
+
317
+ class TestContractLifecycle:
318
+ """Test complete contract lifecycle."""
319
+
320
+ @pytest.fixture
321
+ def contract_engine(self):
322
+ """Create a contract engine for testing."""
323
+ return ContractEngine("sqlite:///:memory:")
324
+
325
+ @pytest.fixture
326
+ def sample_contract_request(self):
327
+ """Create a sample contract creation request."""
328
+ return ContractCreateRequest(
329
+ title="Test Contract",
330
+ description="A test contract for lifecycle testing",
331
+ clauses=[
332
+ Clause(
333
+ title="Test Clause",
334
+ content="This is a test clause",
335
+ type="test"
336
+ )
337
+ ],
338
+ parties=[
339
+ {
340
+ "id": "party1",
341
+ "name": "Test Party 1",
342
+ "type": "provider"
343
+ },
344
+ {
345
+ "id": "party2",
346
+ "name": "Test Party 2",
347
+ "type": "patient"
348
+ }
349
+ ]
350
+ )
351
+
352
+ @pytest.mark.asyncio
353
+ async def test_complete_contract_lifecycle(self, contract_engine, sample_contract_request):
354
+ """Test complete contract lifecycle: create → propose → sign → revoke."""
355
+ created_by = "admin"
356
+
357
+ # 1. Create contract
358
+ contract = await contract_engine.create_contract(sample_contract_request, created_by)
359
+ assert contract.state == ContractState.DRAFT
360
+
361
+ # 2. Propose contract
362
+ proposal_request = ContractProposeRequest(
363
+ proposed_to=["party1", "party2"],
364
+ message="Please review and sign"
365
+ )
366
+ contract = await contract_engine.propose_contract(contract.id, proposal_request, created_by)
367
+ assert contract.state == ContractState.PROPOSED
368
+
369
+ # 3. Sign contract (party1)
370
+ public_key1, private_key1 = generate_key_pair()
371
+ signer1 = Ed25519Signer.from_private_key_b64(private_key1)
372
+ signing_message1 = f"{contract.id}:{contract.get_content_hash()}:party1:provider"
373
+ signature1 = signer1.sign(signing_message1)
374
+
375
+ sign_request1 = ContractSignRequest(
376
+ signer_id="party1",
377
+ signer_type="provider",
378
+ public_key=public_key1,
379
+ signature=signature1
380
+ )
381
+ contract = await contract_engine.sign_contract(contract.id, sign_request1)
382
+ assert len(contract.get_signatures()) == 1
383
+ assert contract.state == ContractState.PROPOSED # Still proposed until all parties sign
384
+
385
+ # 4. Sign contract (party2)
386
+ public_key2, private_key2 = generate_key_pair()
387
+ signer2 = Ed25519Signer.from_private_key_b64(private_key2)
388
+ signing_message2 = f"{contract.id}:{contract.get_content_hash()}:party2:patient"
389
+ signature2 = signer2.sign(signing_message2)
390
+
391
+ sign_request2 = ContractSignRequest(
392
+ signer_id="party2",
393
+ signer_type="patient",
394
+ public_key=public_key2,
395
+ signature=signature2
396
+ )
397
+ contract = await contract_engine.sign_contract(contract.id, sign_request2)
398
+ assert len(contract.get_signatures()) == 2
399
+ assert contract.state == ContractState.SIGNED # Now fully signed
400
+
401
+ # 5. Revoke contract
402
+ revoke_request = ContractRevokeRequest(
403
+ reason="Contract terms violated",
404
+ revoked_by="admin"
405
+ )
406
+ contract = await contract_engine.revoke_contract(contract.id, revoke_request)
407
+ assert contract.state == ContractState.REVOKED
408
+ assert contract.revoked_at is not None
tests/contracts/test_contract_http.py ADDED
@@ -0,0 +1,298 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for contract HTTP endpoints."""
2
+
3
+ import pytest
4
+ from datetime import datetime, timedelta
5
+ from uuid import UUID
6
+
7
+ from fastmcp import FastMCP
8
+ from fastmcp.contracts import ContractEngine, ContractState
9
+ from fastmcp.contracts.contract import (
10
+ Clause, ContractCreateRequest, ContractProposeRequest,
11
+ ContractSignRequest, ContractRevokeRequest
12
+ )
13
+ from fastmcp.contracts.crypto import generate_key_pair, Ed25519Signer
14
+ from starlette.routing import Route
15
+
16
+
17
+ class TestContractHTTPEndpoint:
18
+ """Test the contract management HTTP endpoints."""
19
+
20
+ @pytest.fixture
21
+ def server_with_contracts(self):
22
+ """Create a server with contract engine enabled."""
23
+ server = FastMCP("Test Contract Server")
24
+ # Use in-memory database for testing
25
+ contract_engine = server.enable_contract_engine(database_url="sqlite:///:memory:")
26
+ return server
27
+
28
+ @pytest.fixture
29
+ def app(self, server_with_contracts):
30
+ """Create the HTTP app with contract endpoints."""
31
+ return server_with_contracts.http_app(transport="sse")
32
+
33
+ def test_contract_endpoints_exist(self, app):
34
+ """Test that all contract endpoints exist in the app."""
35
+ expected_paths = [
36
+ "/contracts",
37
+ "/contracts/{id}",
38
+ "/contracts/{id}/propose",
39
+ "/contracts/{id}/sign",
40
+ "/contracts/{id}/revoke",
41
+ "/contracts/statistics"
42
+ ]
43
+
44
+ # Check that all contract routes exist
45
+ contract_routes_found = set()
46
+ for route in app.routes:
47
+ if isinstance(route, Route):
48
+ if route.path in expected_paths:
49
+ contract_routes_found.add(route.path)
50
+
51
+ assert len(contract_routes_found) == len(expected_paths), f"Expected {len(expected_paths)} unique contract routes, found {len(contract_routes_found)}: {sorted(contract_routes_found)}"
52
+
53
+ def test_contract_engine_integration(self, server_with_contracts):
54
+ """Test that contract engine is properly integrated with the server."""
55
+ # Check that contract engine is enabled
56
+ assert server_with_contracts.get_contract_engine() is not None
57
+
58
+ # Check that contract engine is the right type
59
+ contract_engine = server_with_contracts.get_contract_engine()
60
+ assert isinstance(contract_engine, ContractEngine)
61
+
62
+ @pytest.mark.asyncio
63
+ async def test_create_contract_endpoint(self, app):
64
+ """Test the create contract endpoint."""
65
+ import httpx
66
+
67
+ contract_data = {
68
+ "title": "Test Contract",
69
+ "description": "A test contract",
70
+ "clauses": [
71
+ {
72
+ "title": "Test Clause",
73
+ "content": "This is a test clause",
74
+ "type": "test"
75
+ }
76
+ ],
77
+ "parties": [
78
+ {
79
+ "id": "party1",
80
+ "name": "Test Party",
81
+ "type": "provider"
82
+ }
83
+ ],
84
+ "is_hipaa_compliant": False
85
+ }
86
+
87
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
88
+ response = await client.post("/contracts", json=contract_data)
89
+
90
+ assert response.status_code == 201
91
+ data = response.json()
92
+ assert data["title"] == contract_data["title"]
93
+ assert data["description"] == contract_data["description"]
94
+ assert data["state"] == "draft"
95
+ assert data["is_hipaa_compliant"] is False
96
+
97
+ @pytest.mark.asyncio
98
+ async def test_get_contract_endpoint(self, app):
99
+ """Test the get contract endpoint."""
100
+ import httpx
101
+
102
+ # First create a contract
103
+ contract_data = {
104
+ "title": "Test Contract",
105
+ "description": "A test contract",
106
+ "clauses": [],
107
+ "parties": []
108
+ }
109
+
110
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
111
+ # Create contract
112
+ create_response = await client.post("/contracts", json=contract_data)
113
+ assert create_response.status_code == 201
114
+ contract_id = create_response.json()["id"]
115
+
116
+ # Get contract
117
+ get_response = await client.get(f"/contracts/{contract_id}")
118
+ assert get_response.status_code == 200
119
+ data = get_response.json()
120
+ assert data["id"] == contract_id
121
+ assert data["title"] == contract_data["title"]
122
+
123
+ @pytest.mark.asyncio
124
+ async def test_get_contract_not_found(self, app):
125
+ """Test getting a non-existent contract."""
126
+ import httpx
127
+
128
+ fake_id = "12345678-1234-1234-1234-123456789012"
129
+
130
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
131
+ response = await client.get(f"/contracts/{fake_id}")
132
+ assert response.status_code == 404
133
+ data = response.json()
134
+ assert "not found" in data["error"].lower()
135
+
136
+ @pytest.mark.asyncio
137
+ async def test_list_contracts_endpoint(self, app):
138
+ """Test the list contracts endpoint."""
139
+ import httpx
140
+
141
+ # Create multiple contracts
142
+ contract_data = {
143
+ "title": "Test Contract",
144
+ "description": "A test contract",
145
+ "clauses": [],
146
+ "parties": []
147
+ }
148
+
149
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
150
+ # Create contracts
151
+ await client.post("/contracts", json=contract_data)
152
+ await client.post("/contracts", json=contract_data)
153
+
154
+ # List contracts
155
+ response = await client.get("/contracts")
156
+ assert response.status_code == 200
157
+ data = response.json()
158
+ assert "contracts" in data
159
+ assert "count" in data
160
+ assert data["count"] == 2
161
+ assert len(data["contracts"]) == 2
162
+
163
+ @pytest.mark.asyncio
164
+ async def test_propose_contract_endpoint(self, app):
165
+ """Test the propose contract endpoint."""
166
+ import httpx
167
+
168
+ # First create a contract
169
+ contract_data = {
170
+ "title": "Test Contract",
171
+ "description": "A test contract",
172
+ "clauses": [],
173
+ "parties": []
174
+ }
175
+
176
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
177
+ # Create contract
178
+ create_response = await client.post("/contracts", json=contract_data)
179
+ contract_id = create_response.json()["id"]
180
+
181
+ # Propose contract
182
+ proposal_data = {
183
+ "proposed_to": ["party1", "party2"],
184
+ "message": "Please review and sign"
185
+ }
186
+
187
+ response = await client.post(f"/contracts/{contract_id}/propose", json=proposal_data)
188
+ assert response.status_code == 200
189
+ data = response.json()
190
+ assert data["state"] == "proposed"
191
+ assert data["proposed_at"] is not None
192
+
193
+ @pytest.mark.asyncio
194
+ async def test_sign_contract_endpoint(self, app):
195
+ """Test the sign contract endpoint."""
196
+ import httpx
197
+
198
+ # First create and propose a contract
199
+ contract_data = {
200
+ "title": "Test Contract",
201
+ "description": "A test contract",
202
+ "clauses": [],
203
+ "parties": [{"id": "party1", "name": "Test Party", "type": "provider"}]
204
+ }
205
+
206
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
207
+ # Create contract
208
+ create_response = await client.post("/contracts", json=contract_data)
209
+ contract_id = create_response.json()["id"]
210
+
211
+ # Propose contract
212
+ proposal_data = {"proposed_to": ["party1"]}
213
+ await client.post(f"/contracts/{contract_id}/propose", json=proposal_data)
214
+
215
+ # Generate key pair for signing
216
+ public_key, private_key = generate_key_pair()
217
+ signer = Ed25519Signer.from_private_key_b64(private_key)
218
+
219
+ # Get contract to get content hash
220
+ contract_response = await client.get(f"/contracts/{contract_id}")
221
+ contract_data_response = contract_response.json()
222
+ content_hash = contract_data_response["content_hash"]
223
+
224
+ # Create signature
225
+ signing_message = f"{contract_id}:{content_hash}:party1:provider"
226
+ signature = signer.sign(signing_message)
227
+
228
+ # Sign contract
229
+ sign_data = {
230
+ "signer_id": "party1",
231
+ "signer_type": "provider",
232
+ "public_key": public_key,
233
+ "signature": signature
234
+ }
235
+
236
+ response = await client.post(f"/contracts/{contract_id}/sign", json=sign_data)
237
+ assert response.status_code == 200
238
+ data = response.json()
239
+ assert len(data["signatures"]) == 1
240
+ assert data["signatures"][0]["signer_id"] == "party1"
241
+
242
+ @pytest.mark.asyncio
243
+ async def test_revoke_contract_endpoint(self, app):
244
+ """Test the revoke contract endpoint."""
245
+ import httpx
246
+
247
+ # First create a contract
248
+ contract_data = {
249
+ "title": "Test Contract",
250
+ "description": "A test contract",
251
+ "clauses": [],
252
+ "parties": []
253
+ }
254
+
255
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
256
+ # Create contract
257
+ create_response = await client.post("/contracts", json=contract_data)
258
+ contract_id = create_response.json()["id"]
259
+
260
+ # Revoke contract
261
+ revoke_data = {
262
+ "reason": "Contract terms violated",
263
+ "revoked_by": "admin"
264
+ }
265
+
266
+ response = await client.post(f"/contracts/{contract_id}/revoke", json=revoke_data)
267
+ assert response.status_code == 200
268
+ data = response.json()
269
+ assert data["state"] == "revoked"
270
+ assert data["revoked_at"] is not None
271
+
272
+ @pytest.mark.asyncio
273
+ async def test_contract_statistics_endpoint(self, app):
274
+ """Test the contract statistics endpoint."""
275
+ import httpx
276
+
277
+ # Create some contracts
278
+ contract_data = {
279
+ "title": "Test Contract",
280
+ "description": "A test contract",
281
+ "clauses": [],
282
+ "parties": []
283
+ }
284
+
285
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
286
+ # Create contracts
287
+ await client.post("/contracts", json=contract_data)
288
+ await client.post("/contracts", json=contract_data)
289
+
290
+ # Get statistics
291
+ response = await client.get("/contracts/statistics")
292
+ assert response.status_code == 200
293
+ data = response.json()
294
+ assert "total_contracts" in data
295
+ assert "by_state" in data
296
+ assert "hipaa_compliant" in data
297
+ assert "signed_contracts" in data
298
+ assert data["total_contracts"] == 2
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