Spaces:
Running
Running
Merge branch 'main' into feature/reflexive-core
Browse files- examples/contract_example.py +401 -0
- examples/ledger_example.py +208 -0
- src/fastmcp/contracts/__init__.py +7 -0
- src/fastmcp/contracts/contract.py +306 -0
- src/fastmcp/contracts/crypto.py +230 -0
- src/fastmcp/contracts/engine.py +357 -0
- src/fastmcp/contracts/registry.py +391 -0
- src/fastmcp/ledger/__init__.py +17 -0
- src/fastmcp/ledger/adapter.py +384 -0
- src/fastmcp/ledger/ledger.py +504 -0
- src/fastmcp/ledger/merkle.py +244 -0
- src/fastmcp/server/contract_routes.py +425 -0
- src/fastmcp/server/ledger_routes.py +498 -0
- src/fastmcp/server/server.py +68 -1
- tests/contracts/__init__.py +1 -0
- tests/contracts/test_contract_engine.py +408 -0
- tests/contracts/test_contract_http.py +298 -0
- tests/ledger/__init__.py +1 -0
- tests/ledger/test_ledger_adapters.py +234 -0
- tests/ledger/test_ledger_core.py +305 -0
- tests/ledger/test_ledger_http.py +319 -0
- tests/ledger/test_merkle_tree.py +235 -0
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
|