feat: add transaction hash search to blockchain explorer and cleanup settlement storage

Blockchain Explorer:
- Add transaction hash search support (64-char hex pattern validation)
- Fetch and display transaction details in modal (hash, type, from/to, amount, fee, block)
- Fix regex escape sequence in block height validation
- Update search placeholder text to mention both search types
- Add blank lines between function definitions for PEP 8 compliance

Settlement Storage:
- Add timedelta import for future
This commit is contained in:
oib
2026-02-17 14:34:12 +01:00
parent 31d3d70836
commit 421191ccaf
34 changed files with 2176 additions and 5660 deletions

View File

@@ -3,30 +3,26 @@ Storage layer for cross-chain settlements
"""
from typing import Dict, Any, Optional, List
from datetime import datetime
from datetime import datetime, timedelta
import json
import asyncio
from dataclasses import asdict
from .bridges.base import (
SettlementMessage,
SettlementResult,
BridgeStatus
)
from .bridges.base import SettlementMessage, SettlementResult, BridgeStatus
class SettlementStorage:
"""Storage interface for settlement data"""
def __init__(self, db_connection):
self.db = db_connection
async def store_settlement(
self,
message_id: str,
message: SettlementMessage,
bridge_name: str,
status: BridgeStatus
status: BridgeStatus,
) -> None:
"""Store a new settlement record"""
query = """
@@ -38,93 +34,96 @@ class SettlementStorage:
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13
)
"""
await self.db.execute(query, (
message_id,
message.job_id,
message.source_chain_id,
message.target_chain_id,
message.receipt_hash,
json.dumps(message.proof_data),
message.payment_amount,
message.payment_token,
message.nonce,
message.signature,
bridge_name,
status.value,
message.created_at or datetime.utcnow()
))
await self.db.execute(
query,
(
message_id,
message.job_id,
message.source_chain_id,
message.target_chain_id,
message.receipt_hash,
json.dumps(message.proof_data),
message.payment_amount,
message.payment_token,
message.nonce,
message.signature,
bridge_name,
status.value,
message.created_at or datetime.utcnow(),
),
)
async def update_settlement(
self,
message_id: str,
status: Optional[BridgeStatus] = None,
transaction_hash: Optional[str] = None,
error_message: Optional[str] = None,
completed_at: Optional[datetime] = None
completed_at: Optional[datetime] = None,
) -> None:
"""Update settlement record"""
updates = []
params = []
param_count = 1
if status is not None:
updates.append(f"status = ${param_count}")
params.append(status.value)
param_count += 1
if transaction_hash is not None:
updates.append(f"transaction_hash = ${param_count}")
params.append(transaction_hash)
param_count += 1
if error_message is not None:
updates.append(f"error_message = ${param_count}")
params.append(error_message)
param_count += 1
if completed_at is not None:
updates.append(f"completed_at = ${param_count}")
params.append(completed_at)
param_count += 1
if not updates:
return
updates.append(f"updated_at = ${param_count}")
params.append(datetime.utcnow())
param_count += 1
params.append(message_id)
query = f"""
UPDATE settlements
SET {', '.join(updates)}
SET {", ".join(updates)}
WHERE message_id = ${param_count}
"""
await self.db.execute(query, params)
async def get_settlement(self, message_id: str) -> Optional[Dict[str, Any]]:
"""Get settlement by message ID"""
query = """
SELECT * FROM settlements WHERE message_id = $1
"""
result = await self.db.fetchrow(query, message_id)
if not result:
return None
# Convert to dict
settlement = dict(result)
# Parse JSON fields
if settlement['proof_data']:
settlement['proof_data'] = json.loads(settlement['proof_data'])
if settlement["proof_data"]:
settlement["proof_data"] = json.loads(settlement["proof_data"])
return settlement
async def get_settlements_by_job(self, job_id: str) -> List[Dict[str, Any]]:
"""Get all settlements for a job"""
query = """
@@ -132,65 +131,67 @@ class SettlementStorage:
WHERE job_id = $1
ORDER BY created_at DESC
"""
results = await self.db.fetch(query, job_id)
settlements = []
for result in results:
settlement = dict(result)
if settlement['proof_data']:
settlement['proof_data'] = json.loads(settlement['proof_data'])
if settlement["proof_data"]:
settlement["proof_data"] = json.loads(settlement["proof_data"])
settlements.append(settlement)
return settlements
async def get_pending_settlements(self, bridge_name: Optional[str] = None) -> List[Dict[str, Any]]:
async def get_pending_settlements(
self, bridge_name: Optional[str] = None
) -> List[Dict[str, Any]]:
"""Get all pending settlements"""
query = """
SELECT * FROM settlements
WHERE status = 'pending' OR status = 'in_progress'
"""
params = []
if bridge_name:
query += " AND bridge_name = $1"
params.append(bridge_name)
query += " ORDER BY created_at ASC"
results = await self.db.fetch(query, *params)
settlements = []
for result in results:
settlement = dict(result)
if settlement['proof_data']:
settlement['proof_data'] = json.loads(settlement['proof_data'])
if settlement["proof_data"]:
settlement["proof_data"] = json.loads(settlement["proof_data"])
settlements.append(settlement)
return settlements
async def get_settlement_stats(
self,
bridge_name: Optional[str] = None,
time_range: Optional[int] = None # hours
time_range: Optional[int] = None, # hours
) -> Dict[str, Any]:
"""Get settlement statistics"""
conditions = []
params = []
param_count = 1
if bridge_name:
conditions.append(f"bridge_name = ${param_count}")
params.append(bridge_name)
param_count += 1
if time_range:
conditions.append(f"created_at > NOW() - INTERVAL '${param_count} hours'")
params.append(time_range)
param_count += 1
where_clause = "WHERE " + " AND ".join(conditions) if conditions else ""
query = f"""
SELECT
bridge_name,
@@ -202,23 +203,27 @@ class SettlementStorage:
{where_clause}
GROUP BY bridge_name, status
"""
results = await self.db.fetch(query, *params)
stats = {}
for result in results:
bridge = result['bridge_name']
bridge = result["bridge_name"]
if bridge not in stats:
stats[bridge] = {}
stats[bridge][result['status']] = {
'count': result['count'],
'avg_amount': float(result['avg_amount']) if result['avg_amount'] else 0,
'total_amount': float(result['total_amount']) if result['total_amount'] else 0
stats[bridge][result["status"]] = {
"count": result["count"],
"avg_amount": float(result["avg_amount"])
if result["avg_amount"]
else 0,
"total_amount": float(result["total_amount"])
if result["total_amount"]
else 0,
}
return stats
async def cleanup_old_settlements(self, days: int = 30) -> int:
"""Clean up old completed settlements"""
query = """
@@ -226,7 +231,7 @@ class SettlementStorage:
WHERE status IN ('completed', 'failed')
AND created_at < NOW() - INTERVAL $1 days
"""
result = await self.db.execute(query, days)
return result.split()[-1] # Return number of deleted rows
@@ -234,134 +239,139 @@ class SettlementStorage:
# In-memory implementation for testing
class InMemorySettlementStorage(SettlementStorage):
"""In-memory storage implementation for testing"""
def __init__(self):
self.settlements: Dict[str, Dict[str, Any]] = {}
self._lock = asyncio.Lock()
async def store_settlement(
self,
message_id: str,
message: SettlementMessage,
bridge_name: str,
status: BridgeStatus
status: BridgeStatus,
) -> None:
async with self._lock:
self.settlements[message_id] = {
'message_id': message_id,
'job_id': message.job_id,
'source_chain_id': message.source_chain_id,
'target_chain_id': message.target_chain_id,
'receipt_hash': message.receipt_hash,
'proof_data': message.proof_data,
'payment_amount': message.payment_amount,
'payment_token': message.payment_token,
'nonce': message.nonce,
'signature': message.signature,
'bridge_name': bridge_name,
'status': status.value,
'created_at': message.created_at or datetime.utcnow(),
'updated_at': datetime.utcnow()
"message_id": message_id,
"job_id": message.job_id,
"source_chain_id": message.source_chain_id,
"target_chain_id": message.target_chain_id,
"receipt_hash": message.receipt_hash,
"proof_data": message.proof_data,
"payment_amount": message.payment_amount,
"payment_token": message.payment_token,
"nonce": message.nonce,
"signature": message.signature,
"bridge_name": bridge_name,
"status": status.value,
"created_at": message.created_at or datetime.utcnow(),
"updated_at": datetime.utcnow(),
}
async def update_settlement(
self,
message_id: str,
status: Optional[BridgeStatus] = None,
transaction_hash: Optional[str] = None,
error_message: Optional[str] = None,
completed_at: Optional[datetime] = None
completed_at: Optional[datetime] = None,
) -> None:
async with self._lock:
if message_id not in self.settlements:
return
settlement = self.settlements[message_id]
if status is not None:
settlement['status'] = status.value
settlement["status"] = status.value
if transaction_hash is not None:
settlement['transaction_hash'] = transaction_hash
settlement["transaction_hash"] = transaction_hash
if error_message is not None:
settlement['error_message'] = error_message
settlement["error_message"] = error_message
if completed_at is not None:
settlement['completed_at'] = completed_at
settlement['updated_at'] = datetime.utcnow()
settlement["completed_at"] = completed_at
settlement["updated_at"] = datetime.utcnow()
async def get_settlement(self, message_id: str) -> Optional[Dict[str, Any]]:
async with self._lock:
return self.settlements.get(message_id)
async def get_settlements_by_job(self, job_id: str) -> List[Dict[str, Any]]:
async with self._lock:
return [
s for s in self.settlements.values()
if s['job_id'] == job_id
]
async def get_pending_settlements(self, bridge_name: Optional[str] = None) -> List[Dict[str, Any]]:
return [s for s in self.settlements.values() if s["job_id"] == job_id]
async def get_pending_settlements(
self, bridge_name: Optional[str] = None
) -> List[Dict[str, Any]]:
async with self._lock:
pending = [
s for s in self.settlements.values()
if s['status'] in ['pending', 'in_progress']
s
for s in self.settlements.values()
if s["status"] in ["pending", "in_progress"]
]
if bridge_name:
pending = [s for s in pending if s['bridge_name'] == bridge_name]
pending = [s for s in pending if s["bridge_name"] == bridge_name]
return pending
async def get_settlement_stats(
self,
bridge_name: Optional[str] = None,
time_range: Optional[int] = None
self, bridge_name: Optional[str] = None, time_range: Optional[int] = None
) -> Dict[str, Any]:
async with self._lock:
stats = {}
for settlement in self.settlements.values():
if bridge_name and settlement['bridge_name'] != bridge_name:
if bridge_name and settlement["bridge_name"] != bridge_name:
continue
# TODO: Implement time range filtering
bridge = settlement['bridge_name']
# Time range filtering
if time_range is not None:
cutoff = datetime.utcnow() - timedelta(hours=time_range)
if settlement["created_at"] < cutoff:
continue
bridge = settlement["bridge_name"]
if bridge not in stats:
stats[bridge] = {}
status = settlement['status']
status = settlement["status"]
if status not in stats[bridge]:
stats[bridge][status] = {
'count': 0,
'avg_amount': 0,
'total_amount': 0
"count": 0,
"avg_amount": 0,
"total_amount": 0,
}
stats[bridge][status]['count'] += 1
stats[bridge][status]['total_amount'] += settlement['payment_amount']
stats[bridge][status]["count"] += 1
stats[bridge][status]["total_amount"] += settlement["payment_amount"]
# Calculate averages
for bridge_data in stats.values():
for status_data in bridge_data.values():
if status_data['count'] > 0:
status_data['avg_amount'] = status_data['total_amount'] / status_data['count']
if status_data["count"] > 0:
status_data["avg_amount"] = (
status_data["total_amount"] / status_data["count"]
)
return stats
async def cleanup_old_settlements(self, days: int = 30) -> int:
async with self._lock:
cutoff = datetime.utcnow() - timedelta(days=days)
to_delete = [
msg_id for msg_id, settlement in self.settlements.items()
msg_id
for msg_id, settlement in self.settlements.items()
if (
settlement['status'] in ['completed', 'failed'] and
settlement['created_at'] < cutoff
settlement["status"] in ["completed", "failed"]
and settlement["created_at"] < cutoff
)
]
for msg_id in to_delete:
del self.settlements[msg_id]
return len(to_delete)