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