chore: remove obsolete payment architecture and integration test documentation

- Remove AITBC_PAYMENT_ARCHITECTURE.md (dual-currency system documentation)
- Remove IMPLEMENTATION_COMPLETE_SUMMARY.md (integration test completion summary)
- Remove INTEGRATION_TEST_FIXES.md (test fixes documentation)
- Remove INTEGRATION_TEST_UPDATES.md (real features implementation notes)
- Remove PAYMENT_INTEGRATION_COMPLETE.md (wallet-coordinator integration docs)
- Remove WALLET_COORDINATOR_INTEGRATION.md (payment
This commit is contained in:
oib
2026-01-29 12:28:43 +01:00
parent 5c99c92ffb
commit ff4554b9dd
94 changed files with 7925 additions and 128 deletions

View File

@@ -0,0 +1,195 @@
import { createApp } from 'https://unpkg.com/vue@3/dist/vue.esm-browser.js'
const API_BASE = '/api/v1'
const app = createApp({
data() {
return {
loading: true,
chainInfo: {
height: 0,
hash: '',
timestamp: null,
tx_count: 0
},
latestBlocks: [],
stats: {
totalBlocks: 0,
totalTransactions: 0,
avgBlockTime: 2.0,
hashRate: '0 H/s'
},
error: null
}
},
async mounted() {
await this.loadChainData()
setInterval(this.loadChainData, 5000)
},
methods: {
async loadChainData() {
try {
this.error = null
// Load chain head
const headResponse = await fetch(`${API_BASE}/chain/head`)
if (headResponse.ok) {
this.chainInfo = await headResponse.json()
}
// Load latest blocks
const blocksResponse = await fetch(`${API_BASE}/chain/blocks?limit=10`)
if (blocksResponse.ok) {
this.latestBlocks = await blocksResponse.json()
}
// Calculate stats
this.stats.totalBlocks = this.chainInfo.height || 0
this.stats.totalTransactions = this.latestBlocks.reduce((sum, block) => sum + (block.tx_count || 0), 0)
this.loading = false
} catch (error) {
console.error('Failed to load chain data:', error)
this.error = 'Failed to connect to blockchain node'
this.loading = false
}
},
formatHash(hash) {
if (!hash) return '-'
return hash.substring(0, 10) + '...' + hash.substring(hash.length - 8)
},
formatTime(timestamp) {
if (!timestamp) return '-'
return new Date(timestamp * 1000).toLocaleString()
},
formatNumber(num) {
if (!num) return '0'
return num.toLocaleString()
},
getBlockType(block) {
if (!block) return 'unknown'
return block.tx_count > 0 ? 'with-tx' : 'empty'
}
},
template: `
<div class="app">
<!-- Header -->
<header class="header">
<div class="container">
<div class="header-content">
<div class="logo">
<svg class="logo-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M12 2L2 7L12 12L22 7L12 2Z"></path>
<path d="M2 17L12 22L22 17"></path>
<path d="M2 12L12 17L22 12"></path>
</svg>
<h1>AITBC Explorer</h1>
</div>
<div class="header-stats">
<div class="stat">
<span class="stat-label">Height</span>
<span class="stat-value">{{ formatNumber(chainInfo.height) }}</span>
</div>
</div>
</div>
</div>
</header>
<!-- Main Content -->
<main class="main">
<div class="container">
<!-- Loading State -->
<div v-if="loading" class="loading">
<div class="spinner"></div>
<p>Loading blockchain data...</p>
</div>
<!-- Error State -->
<div v-else-if="error" class="error">
<svg class="error-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
<p>{{ error }}</p>
<button @click="loadChainData" class="retry-btn">Retry</button>
</div>
<!-- Chain Overview -->
<div v-else class="overview">
<div class="cards">
<div class="card">
<div class="card-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"></path>
</svg>
</div>
<div class="card-content">
<h3>Current Height</h3>
<p class="card-value">{{ formatNumber(chainInfo.height) }}</p>
</div>
</div>
<div class="card">
<div class="card-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
</svg>
</div>
<div class="card-content">
<h3>Latest Block</h3>
<p class="card-value hash">{{ formatHash(chainInfo.hash) }}</p>
</div>
</div>
<div class="card">
<div class="card-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path>
</svg>
</div>
<div class="card-content">
<h3>Total Transactions</h3>
<p class="card-value">{{ formatNumber(stats.totalTransactions) }}</p>
</div>
</div>
</div>
</div>
<!-- Latest Blocks -->
<div v-if="!loading && !error" class="blocks-section">
<h2>Latest Blocks</h2>
<div class="blocks-list">
<div v-for="block in latestBlocks" :key="block.height"
class="block-item" :class="getBlockType(block)">
<div class="block-height">
<span class="height">{{ formatNumber(block.height) }}</span>
<span v-if="block.tx_count > 0" class="tx-badge">{{ block.tx_count }} tx</span>
</div>
<div class="block-details">
<div class="block-hash">
<span class="label">Hash:</span>
<span class="value">{{ formatHash(block.hash) }}</span>
</div>
<div class="block-time">
<span class="label">Time:</span>
<span class="value">{{ formatTime(block.timestamp) }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
`
})
app.mount('#app')

View File

@@ -0,0 +1,315 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--primary-color: #3b82f6;
--primary-dark: #2563eb;
--secondary-color: #10b981;
--background: #f9fafb;
--surface: #ffffff;
--text-primary: #111827;
--text-secondary: #6b7280;
--border-color: #e5e7eb;
--error-color: #ef4444;
--success-color: #22c55e;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: var(--background);
color: var(--text-primary);
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
}
/* Header */
.header {
background: var(--surface);
border-bottom: 1px solid var(--border-color);
position: sticky;
top: 0;
z-index: 100;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 0;
}
.logo {
display: flex;
align-items: center;
gap: 0.75rem;
}
.logo-icon {
width: 2rem;
height: 2rem;
color: var(--primary-color);
}
.logo h1 {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary);
}
.header-stats {
display: flex;
gap: 2rem;
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
}
.stat-label {
font-size: 0.875rem;
color: var(--text-secondary);
}
.stat-value {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
}
/* Main Content */
.main {
padding: 2rem 0;
min-height: calc(100vh - 80px);
}
/* Loading State */
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 0;
color: var(--text-secondary);
}
.spinner {
width: 3rem;
height: 3rem;
border: 3px solid var(--border-color);
border-top-color: var(--primary-color);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Error State */
.error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 0;
color: var(--error-color);
}
.error-icon {
width: 3rem;
height: 3rem;
margin-bottom: 1rem;
}
.retry-btn {
margin-top: 1rem;
padding: 0.5rem 1rem;
background: var(--primary-color);
color: white;
border: none;
border-radius: 0.375rem;
font-size: 0.875rem;
cursor: pointer;
transition: background-color 0.2s;
}
.retry-btn:hover {
background: var(--primary-dark);
}
/* Overview Cards */
.overview {
margin-bottom: 2rem;
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
}
.card {
background: var(--surface);
border-radius: 0.75rem;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
gap: 1rem;
transition: transform 0.2s, box-shadow 0.2s;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.card-icon {
width: 3rem;
height: 3rem;
background: var(--primary-color);
color: white;
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.card-icon svg {
width: 1.5rem;
height: 1.5rem;
}
.card-content h3 {
font-size: 0.875rem;
color: var(--text-secondary);
margin-bottom: 0.25rem;
}
.card-value {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary);
}
.card-value.hash {
font-family: 'Courier New', monospace;
font-size: 1rem;
}
/* Blocks Section */
.blocks-section h2 {
font-size: 1.5rem;
margin-bottom: 1rem;
color: var(--text-primary);
}
.blocks-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.block-item {
background: var(--surface);
border-radius: 0.5rem;
padding: 1rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
gap: 1rem;
transition: transform 0.2s, box-shadow 0.2s;
}
.block-item:hover {
transform: translateX(4px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.block-item.empty {
opacity: 0.7;
}
.block-height {
display: flex;
flex-direction: column;
align-items: center;
min-width: 80px;
}
.height {
font-size: 1.25rem;
font-weight: 600;
color: var(--primary-color);
}
.tx-badge {
margin-top: 0.25rem;
padding: 0.125rem 0.5rem;
background: var(--success-color);
color: white;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.block-details {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.block-hash,
.block-time {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
}
.label {
color: var(--text-secondary);
font-weight: 500;
}
.value {
color: var(--text-primary);
font-family: 'Courier New', monospace;
}
/* Responsive Design */
@media (max-width: 768px) {
.header-content {
flex-direction: column;
gap: 1rem;
}
.cards {
grid-template-columns: 1fr;
}
.block-item {
flex-direction: column;
align-items: flex-start;
}
.block-height {
flex-direction: row;
width: 100%;
justify-content: space-between;
}
}

View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/assets/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AITBC Explorer</title>
<script type="module" crossorigin src="/assets/index.js"></script>
<link rel="stylesheet" crossorigin href="/assets/style.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

View File

@@ -0,0 +1,372 @@
#!/usr/bin/env python3
"""
AITBC Blockchain Explorer
A simple web interface to explore the blockchain
"""
import asyncio
import httpx
import json
from datetime import datetime
from typing import Dict, List, Optional, Any
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
import uvicorn
app = FastAPI(title="AITBC Blockchain Explorer", version="1.0.0")
# Configuration
BLOCKCHAIN_RPC_URL = "http://localhost:8082" # Local blockchain node
EXTERNAL_RPC_URL = "http://aitbc.keisanki.net:8082" # External access
# HTML Template
HTML_TEMPLATE = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AITBC Blockchain Explorer</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<style>
.fade-in { animation: fadeIn 0.3s ease-in; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
</style>
</head>
<body class="bg-gray-50">
<header class="bg-blue-600 text-white shadow-lg">
<div class="container mx-auto px-4 py-4">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<i data-lucide="cube" class="w-8 h-8"></i>
<h1 class="text-2xl font-bold">AITBC Blockchain Explorer</h1>
</div>
<div class="flex items-center space-x-4">
<span class="text-sm">Network: <span class="font-mono bg-blue-700 px-2 py-1 rounded">ait-devnet</span></span>
<button onclick="refreshData()" class="bg-blue-500 hover:bg-blue-400 px-3 py-1 rounded flex items-center space-x-1">
<i data-lucide="refresh-cw" class="w-4 h-4"></i>
<span>Refresh</span>
</button>
</div>
</div>
</div>
</header>
<main class="container mx-auto px-4 py-8">
<!-- Chain Stats -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div class="bg-white rounded-lg shadow p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-500 text-sm">Current Height</p>
<p class="text-2xl font-bold" id="chain-height">-</p>
</div>
<i data-lucide="trending-up" class="w-10 h-10 text-green-500"></i>
</div>
</div>
<div class="bg-white rounded-lg shadow p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-500 text-sm">Latest Block</p>
<p class="text-lg font-mono" id="latest-hash">-</p>
</div>
<i data-lucide="hash" class="w-10 h-10 text-blue-500"></i>
</div>
</div>
<div class="bg-white rounded-lg shadow p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-500 text-sm">Node Status</p>
<p class="text-lg font-semibold" id="node-status">-</p>
</div>
<i data-lucide="activity" class="w-10 h-10 text-purple-500"></i>
</div>
</div>
</div>
<!-- Search -->
<div class="bg-white rounded-lg shadow p-6 mb-8">
<div class="flex space-x-4">
<input type="text" id="search-input" placeholder="Search by block height, hash, or transaction hash"
class="flex-1 px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<button onclick="search()" class="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700">
Search
</button>
</div>
</div>
<!-- Latest Blocks -->
<div class="bg-white rounded-lg shadow">
<div class="px-6 py-4 border-b">
<h2 class="text-xl font-semibold flex items-center">
<i data-lucide="blocks" class="w-5 h-5 mr-2"></i>
Latest Blocks
</h2>
</div>
<div class="p-6">
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="text-left text-gray-500 text-sm">
<th class="pb-3">Height</th>
<th class="pb-3">Hash</th>
<th class="pb-3">Timestamp</th>
<th class="pb-3">Transactions</th>
<th class="pb-3">Actions</th>
</tr>
</thead>
<tbody id="blocks-table">
<tr>
<td colspan="5" class="text-center py-8 text-gray-500">
Loading blocks...
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Block Details Modal -->
<div id="block-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50">
<div class="flex items-center justify-center min-h-screen p-4">
<div class="bg-white rounded-lg max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div class="p-6 border-b">
<div class="flex justify-between items-center">
<h2 class="text-2xl font-bold">Block Details</h2>
<button onclick="closeModal()" class="text-gray-500 hover:text-gray-700">
<i data-lucide="x" class="w-6 h-6"></i>
</button>
</div>
</div>
<div class="p-6" id="block-details">
<!-- Block details will be loaded here -->
</div>
</div>
</div>
</div>
</main>
<footer class="bg-gray-800 text-white mt-12">
<div class="container mx-auto px-4 py-6 text-center">
<p class="text-sm">AITBC Blockchain Explorer - Connected to node at {node_url}</p>
</div>
</footer>
<script>
// Initialize lucide icons
lucide.createIcons();
// Global state
let currentData = {};
// Load initial data
document.addEventListener('DOMContentLoaded', () => {
refreshData();
});
// Refresh all data
async function refreshData() {
try {
await Promise.all([
loadChainStats(),
loadLatestBlocks()
]);
} catch (error) {
console.error('Error refreshing data:', error);
document.getElementById('node-status').innerHTML = '<span class="text-red-500">Error</span>';
}
}
// Load chain statistics
async function loadChainStats() {
const response = await fetch('/api/chain/head');
const data = await response.json();
document.getElementById('chain-height').textContent = data.height || '-';
document.getElementById('latest-hash').textContent = data.hash ? data.hash.substring(0, 16) + '...' : '-';
document.getElementById('node-status').innerHTML = '<span class="text-green-500">Online</span>';
currentData.head = data;
}
// Load latest blocks
async function loadLatestBlocks() {
const tbody = document.getElementById('blocks-table');
tbody.innerHTML = '<tr><td colspan="5" class="text-center py-8 text-gray-500">Loading blocks...</td></tr>';
const head = await fetch('/api/chain/head').then(r => r.json());
const blocks = [];
// Load last 10 blocks
for (let i = 0; i < 10 && head.height - i >= 0; i++) {
const block = await fetch(`/api/blocks/${head.height - i}`).then(r => r.json());
blocks.push(block);
}
tbody.innerHTML = blocks.map(block => `
<tr class="border-t hover:bg-gray-50">
<td class="py-3 font-mono">${block.height}</td>
<td class="py-3 font-mono text-sm">${block.hash ? block.hash.substring(0, 16) + '...' : '-'}</td>
<td class="py-3 text-sm">${formatTimestamp(block.timestamp)}</td>
<td class="py-3">${block.transactions ? block.transactions.length : 0}</td>
<td class="py-3">
<button onclick="showBlockDetails(${block.height})" class="text-blue-600 hover:text-blue-800">
View Details
</button>
</td>
</tr>
`).join('');
}
// Show block details
async function showBlockDetails(height) {
const block = await fetch(`/api/blocks/${height}`).then(r => r.json());
const modal = document.getElementById('block-modal');
const details = document.getElementById('block-details');
details.innerHTML = `
<div class="space-y-6">
<div>
<h3 class="text-lg font-semibold mb-2">Block Header</h3>
<div class="bg-gray-50 rounded p-4 space-y-2">
<div class="flex justify-between">
<span class="text-gray-600">Height:</span>
<span class="font-mono">${block.height}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">Hash:</span>
<span class="font-mono text-sm">${block.hash || '-'}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">Parent Hash:</span>
<span class="font-mono text-sm">${block.parent_hash || '-'}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">Timestamp:</span>
<span>${formatTimestamp(block.timestamp)}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">Proposer:</span>
<span class="font-mono text-sm">${block.proposer || '-'}</span>
</div>
</div>
</div>
${block.transactions && block.transactions.length > 0 ? `
<div>
<h3 class="text-lg font-semibold mb-2">Transactions (${block.transactions.length})</h3>
<div class="space-y-2">
${block.transactions.map(tx => `
<div class="bg-gray-50 rounded p-4">
<div class="flex justify-between mb-2">
<span class="text-gray-600">Hash:</span>
<span class="font-mono text-sm">${tx.hash || '-'}</span>
</div>
<div class="flex justify-between mb-2">
<span class="text-gray-600">Type:</span>
<span>${tx.type || '-'}</span>
</div>
<div class="flex justify-between mb-2">
<span class="text-gray-600">From:</span>
<span class="font-mono text-sm">${tx.sender || '-'}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">Fee:</span>
<span>${tx.fee || '0'}</span>
</div>
</div>
`).join('')}
</div>
</div>
` : '<p class="text-gray-500">No transactions in this block</p>'}
</div>
`;
modal.classList.remove('hidden');
}
// Close modal
function closeModal() {
document.getElementById('block-modal').classList.add('hidden');
}
// Search functionality
async function search() {
const query = document.getElementById('search-input').value.trim();
if (!query) return;
// Try block height first
if (/^\\d+$/.test(query)) {
showBlockDetails(parseInt(query));
return;
}
// TODO: Add transaction hash search
alert('Search by block height is currently supported');
}
// Format timestamp
function formatTimestamp(timestamp) {
if (!timestamp) return '-';
return new Date(timestamp * 1000).toLocaleString();
}
// Auto-refresh every 30 seconds
setInterval(refreshData, 30000);
</script>
</body>
</html>
"""
async def get_chain_head() -> Dict[str, Any]:
"""Get the current chain head"""
try:
async with httpx.AsyncClient() as client:
response = await client.get(f"{BLOCKCHAIN_RPC_URL}/rpc/head")
if response.status_code == 200:
return response.json()
except Exception as e:
print(f"Error getting chain head: {e}")
return {}
async def get_block(height: int) -> Dict[str, Any]:
"""Get a specific block by height"""
try:
async with httpx.AsyncClient() as client:
response = await client.get(f"{BLOCKCHAIN_RPC_URL}/rpc/blocks/{height}")
if response.status_code == 200:
return response.json()
except Exception as e:
print(f"Error getting block {height}: {e}")
return {}
@app.get("/", response_class=HTMLResponse)
async def root():
"""Serve the explorer UI"""
return HTML_TEMPLATE.format(node_url=BLOCKCHAIN_RPC_URL)
@app.get("/api/chain/head")
async def api_chain_head():
"""API endpoint for chain head"""
return await get_chain_head()
@app.get("/api/blocks/{height}")
async def api_block(height: int):
"""API endpoint for block data"""
return await get_block(height)
@app.get("/health")
async def health():
"""Health check endpoint"""
head = await get_chain_head()
return {
"status": "ok" if head else "error",
"node_url": BLOCKCHAIN_RPC_URL,
"chain_height": head.get("height", 0)
}
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=3000)

View File

@@ -0,0 +1,31 @@
server {
listen 3000;
server_name _;
root /opt/blockchain-explorer;
index index.html;
# API proxy - standardize endpoints
location /api/v1/ {
proxy_pass http://localhost:8082/rpc/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Legacy API compatibility
location /rpc {
return 307 /api/v1$uri?$args;
}
# Static files
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -0,0 +1,3 @@
fastapi==0.111.1
uvicorn[standard]==0.30.6
httpx==0.27.2

View File

@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""
Simple gossip relay service for blockchain nodes
Uses Starlette Broadcast to share messages between nodes
"""
import argparse
import asyncio
import logging
from typing import Any, Dict
from starlette.applications import Starlette
from starlette.broadcast import Broadcast
from starlette.middleware import Middleware
from starlette.middleware.cors import CORSMiddleware
from starlette.routing import Route, WebSocketRoute
from starlette.websockets import WebSocket
import uvicorn
# Setup logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Global broadcast instance
broadcast = Broadcast("memory://")
async def gossip_endpoint(request):
"""HTTP endpoint for publishing gossip messages"""
try:
data = await request.json()
channel = data.get("channel", "blockchain")
message = data.get("message")
if message:
await broadcast.publish(channel, message)
logger.info(f"Published to {channel}: {str(message)[:50]}...")
return {"status": "published", "channel": channel}
else:
return {"status": "error", "message": "No message provided"}
except Exception as e:
logger.error(f"Error publishing: {e}")
return {"status": "error", "message": str(e)}
async def websocket_endpoint(websocket: WebSocket):
"""WebSocket endpoint for real-time gossip"""
await websocket.accept()
# Get channel from query params
channel = websocket.query_params.get("channel", "blockchain")
logger.info(f"WebSocket connected to channel: {channel}")
try:
async with broadcast.subscribe(channel) as subscriber:
async for message in subscriber:
await websocket.send_text(message)
except Exception as e:
logger.error(f"WebSocket error: {e}")
finally:
logger.info("WebSocket disconnected")
def create_app() -> Starlette:
"""Create the Starlette application"""
routes = [
Route("/gossip", gossip_endpoint, methods=["POST"]),
WebSocketRoute("/ws", websocket_endpoint),
]
middleware = [
Middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"])
]
return Starlette(routes=routes, middleware=middleware)
def main():
parser = argparse.ArgumentParser(description="AITBC Gossip Relay")
parser.add_argument("--host", default="127.0.0.1", help="Bind host")
parser.add_argument("--port", type=int, default=7070, help="Bind port")
parser.add_argument("--log-level", default="info", help="Log level")
args = parser.parse_args()
logger.info(f"Starting gossip relay on {args.host}:{args.port}")
app = create_app()
uvicorn.run(
app,
host=args.host,
port=args.port,
log_level=args.log_level
)
if __name__ == "__main__":
main()

View File

@@ -105,6 +105,61 @@ async def get_block(height: int) -> Dict[str, Any]:
}
@router.get("/blocks", summary="Get latest blocks")
async def get_blocks(limit: int = 10, offset: int = 0) -> Dict[str, Any]:
metrics_registry.increment("rpc_get_blocks_total")
start = time.perf_counter()
# Validate parameters
if limit < 1 or limit > 100:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="limit must be between 1 and 100")
if offset < 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="offset must be non-negative")
with session_scope() as session:
# Get blocks in descending order (newest first)
blocks = session.exec(
select(Block)
.order_by(Block.height.desc())
.offset(offset)
.limit(limit)
).all()
# Get total count for pagination info
total_count = len(session.exec(select(Block)).all())
if not blocks:
metrics_registry.increment("rpc_get_blocks_empty_total")
return {
"blocks": [],
"total": total_count,
"limit": limit,
"offset": offset,
}
# Serialize blocks
block_list = []
for block in blocks:
block_list.append({
"height": block.height,
"hash": block.hash,
"parent_hash": block.parent_hash,
"timestamp": block.timestamp.isoformat(),
"tx_count": block.tx_count,
"state_root": block.state_root,
})
metrics_registry.increment("rpc_get_blocks_success_total")
metrics_registry.observe("rpc_get_blocks_duration_seconds", time.perf_counter() - start)
return {
"blocks": block_list,
"total": total_count,
"limit": limit,
"offset": offset,
}
@router.get("/tx/{tx_hash}", summary="Get transaction by hash")
async def get_transaction(tx_hash: str) -> Dict[str, Any]:
metrics_registry.increment("rpc_get_transaction_total")
@@ -126,6 +181,61 @@ async def get_transaction(tx_hash: str) -> Dict[str, Any]:
}
@router.get("/transactions", summary="Get latest transactions")
async def get_transactions(limit: int = 20, offset: int = 0) -> Dict[str, Any]:
metrics_registry.increment("rpc_get_transactions_total")
start = time.perf_counter()
# Validate parameters
if limit < 1 or limit > 100:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="limit must be between 1 and 100")
if offset < 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="offset must be non-negative")
with session_scope() as session:
# Get transactions in descending order (newest first)
transactions = session.exec(
select(Transaction)
.order_by(Transaction.created_at.desc())
.offset(offset)
.limit(limit)
).all()
# Get total count for pagination info
total_count = len(session.exec(select(Transaction)).all())
if not transactions:
metrics_registry.increment("rpc_get_transactions_empty_total")
return {
"transactions": [],
"total": total_count,
"limit": limit,
"offset": offset,
}
# Serialize transactions
tx_list = []
for tx in transactions:
tx_list.append({
"tx_hash": tx.tx_hash,
"block_height": tx.block_height,
"sender": tx.sender,
"recipient": tx.recipient,
"payload": tx.payload,
"created_at": tx.created_at.isoformat(),
})
metrics_registry.increment("rpc_get_transactions_success_total")
metrics_registry.observe("rpc_get_transactions_duration_seconds", time.perf_counter() - start)
return {
"transactions": tx_list,
"total": total_count,
"limit": limit,
"offset": offset,
}
@router.get("/receipts/{receipt_id}", summary="Get receipt by ID")
async def get_receipt(receipt_id: str) -> Dict[str, Any]:
metrics_registry.increment("rpc_get_receipt_total")
@@ -140,6 +250,54 @@ async def get_receipt(receipt_id: str) -> Dict[str, Any]:
return _serialize_receipt(receipt)
@router.get("/receipts", summary="Get latest receipts")
async def get_receipts(limit: int = 20, offset: int = 0) -> Dict[str, Any]:
metrics_registry.increment("rpc_get_receipts_total")
start = time.perf_counter()
# Validate parameters
if limit < 1 or limit > 100:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="limit must be between 1 and 100")
if offset < 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="offset must be non-negative")
with session_scope() as session:
# Get receipts in descending order (newest first)
receipts = session.exec(
select(Receipt)
.order_by(Receipt.recorded_at.desc())
.offset(offset)
.limit(limit)
).all()
# Get total count for pagination info
total_count = len(session.exec(select(Receipt)).all())
if not receipts:
metrics_registry.increment("rpc_get_receipts_empty_total")
return {
"receipts": [],
"total": total_count,
"limit": limit,
"offset": offset,
}
# Serialize receipts
receipt_list = []
for receipt in receipts:
receipt_list.append(_serialize_receipt(receipt))
metrics_registry.increment("rpc_get_receipts_success_total")
metrics_registry.observe("rpc_get_receipts_duration_seconds", time.perf_counter() - start)
return {
"receipts": receipt_list,
"total": total_count,
"limit": limit,
"offset": offset,
}
@router.get("/getBalance/{address}", summary="Get account balance")
async def get_balance(address: str) -> Dict[str, Any]:
metrics_registry.increment("rpc_get_balance_total")
@@ -160,6 +318,131 @@ async def get_balance(address: str) -> Dict[str, Any]:
}
@router.get("/address/{address}", summary="Get address details including transactions")
async def get_address_details(address: str, limit: int = 20, offset: int = 0) -> Dict[str, Any]:
metrics_registry.increment("rpc_get_address_total")
start = time.perf_counter()
# Validate parameters
if limit < 1 or limit > 100:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="limit must be between 1 and 100")
if offset < 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="offset must be non-negative")
with session_scope() as session:
# Get account info
account = session.get(Account, address)
# Get transactions where this address is sender or recipient
sent_txs = session.exec(
select(Transaction)
.where(Transaction.sender == address)
.order_by(Transaction.created_at.desc())
.offset(offset)
.limit(limit)
).all()
received_txs = session.exec(
select(Transaction)
.where(Transaction.recipient == address)
.order_by(Transaction.created_at.desc())
.offset(offset)
.limit(limit)
).all()
# Get total counts
total_sent = len(session.exec(select(Transaction).where(Transaction.sender == address)).all())
total_received = len(session.exec(select(Transaction).where(Transaction.recipient == address)).all())
# Serialize transactions
serialize_tx = lambda tx: {
"tx_hash": tx.tx_hash,
"block_height": tx.block_height,
"direction": "sent" if tx.sender == address else "received",
"counterparty": tx.recipient if tx.sender == address else tx.sender,
"payload": tx.payload,
"created_at": tx.created_at.isoformat(),
}
response = {
"address": address,
"balance": account.balance if account else 0,
"nonce": account.nonce if account else 0,
"total_transactions_sent": total_sent,
"total_transactions_received": total_received,
"latest_sent": [serialize_tx(tx) for tx in sent_txs],
"latest_received": [serialize_tx(tx) for tx in received_txs],
}
if account:
response["updated_at"] = account.updated_at.isoformat()
metrics_registry.increment("rpc_get_address_success_total")
metrics_registry.observe("rpc_get_address_duration_seconds", time.perf_counter() - start)
return response
@router.get("/addresses", summary="Get list of active addresses")
async def get_addresses(limit: int = 20, offset: int = 0, min_balance: int = 0) -> Dict[str, Any]:
metrics_registry.increment("rpc_get_addresses_total")
start = time.perf_counter()
# Validate parameters
if limit < 1 or limit > 100:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="limit must be between 1 and 100")
if offset < 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="offset must be non-negative")
with session_scope() as session:
# Get addresses with balance >= min_balance
addresses = session.exec(
select(Account)
.where(Account.balance >= min_balance)
.order_by(Account.balance.desc())
.offset(offset)
.limit(limit)
).all()
# Get total count
total_count = len(session.exec(select(Account).where(Account.balance >= min_balance)).all())
if not addresses:
metrics_registry.increment("rpc_get_addresses_empty_total")
return {
"addresses": [],
"total": total_count,
"limit": limit,
"offset": offset,
}
# Serialize addresses
address_list = []
for addr in addresses:
# Get transaction counts
sent_count = len(session.exec(select(Transaction).where(Transaction.sender == addr.address)).all())
received_count = len(session.exec(select(Transaction).where(Transaction.recipient == addr.address)).all())
address_list.append({
"address": addr.address,
"balance": addr.balance,
"nonce": addr.nonce,
"total_transactions_sent": sent_count,
"total_transactions_received": received_count,
"updated_at": addr.updated_at.isoformat(),
})
metrics_registry.increment("rpc_get_addresses_success_total")
metrics_registry.observe("rpc_get_addresses_duration_seconds", time.perf_counter() - start)
return {
"addresses": address_list,
"total": total_count,
"limit": limit,
"offset": offset,
}
@router.post("/sendTx", summary="Submit a new transaction")
async def send_transaction(request: TransactionRequest) -> Dict[str, Any]:
metrics_registry.increment("rpc_send_tx_total")

View File

@@ -24,7 +24,7 @@ CREATE TABLE IF NOT EXISTS job_payments (
released_at TIMESTAMP,
refunded_at TIMESTAMP,
expires_at TIMESTAMP,
metadata JSON
meta_data JSON
);
-- Create payment_escrows table

View File

@@ -3,8 +3,9 @@
from .job import Job
from .miner import Miner
from .job_receipt import JobReceipt
from .marketplace import MarketplaceOffer, MarketplaceBid, OfferStatus
from .marketplace import MarketplaceOffer, MarketplaceBid
from .user import User, Wallet
from .payment import JobPayment, PaymentEscrow
__all__ = [
"Job",
@@ -12,7 +13,8 @@ __all__ = [
"JobReceipt",
"MarketplaceOffer",
"MarketplaceBid",
"OfferStatus",
"User",
"Wallet",
"JobPayment",
"PaymentEscrow",
]

View File

@@ -4,17 +4,18 @@ from datetime import datetime
from typing import Optional
from uuid import uuid4
from sqlalchemy import Column, JSON, String
from sqlmodel import Field, SQLModel, Relationship
from ..types import JobState
from sqlalchemy import Column, JSON, String, ForeignKey
from sqlalchemy.orm import Mapped, relationship
from sqlmodel import Field, SQLModel
class Job(SQLModel, table=True):
__tablename__ = "job"
id: str = Field(default_factory=lambda: uuid4().hex, primary_key=True, index=True)
client_id: str = Field(index=True)
state: JobState = Field(default=JobState.queued, sa_column_kwargs={"nullable": False})
state: str = Field(default="QUEUED", max_length=20)
payload: dict = Field(sa_column=Column(JSON, nullable=False))
constraints: dict = Field(default_factory=dict, sa_column=Column(JSON, nullable=False))
@@ -30,8 +31,8 @@ class Job(SQLModel, table=True):
error: Optional[str] = None
# Payment tracking
payment_id: Optional[str] = Field(default=None, foreign_key="job_payments.id", index=True)
payment_id: Optional[str] = Field(default=None, sa_column=Column(String, ForeignKey("job_payments.id"), index=True))
payment_status: Optional[str] = Field(default=None, max_length=20) # pending, escrowed, released, refunded
# Relationships
payment: Optional["JobPayment"] = Relationship(back_populates="jobs")
# payment: Mapped[Optional["JobPayment"]] = relationship(back_populates="jobs")

View File

@@ -1,27 +1,20 @@
from __future__ import annotations
from datetime import datetime
from enum import Enum
from typing import Optional
from uuid import uuid4
from sqlalchemy import Column, Enum as SAEnum, JSON
from sqlalchemy import Column, JSON
from sqlmodel import Field, SQLModel
class OfferStatus(str, Enum):
open = "open"
reserved = "reserved"
closed = "closed"
class MarketplaceOffer(SQLModel, table=True):
id: str = Field(default_factory=lambda: uuid4().hex, primary_key=True)
provider: str = Field(index=True)
capacity: int = Field(default=0, nullable=False)
price: float = Field(default=0.0, nullable=False)
sla: str = Field(default="")
status: OfferStatus = Field(default=OfferStatus.open, sa_column=Column(SAEnum(OfferStatus), nullable=False))
status: str = Field(default="open", max_length=20)
created_at: datetime = Field(default_factory=datetime.utcnow, nullable=False, index=True)
attributes: dict = Field(default_factory=dict, sa_column=Column(JSON, nullable=False))

View File

@@ -6,10 +6,9 @@ from datetime import datetime
from typing import Optional, List
from uuid import uuid4
from sqlalchemy import Column, String, DateTime, Numeric, ForeignKey
from sqlmodel import Field, SQLModel, Relationship
from ..schemas.payments import PaymentStatus, PaymentMethod
from sqlalchemy import Column, String, DateTime, Numeric, ForeignKey, JSON
from sqlalchemy.orm import Mapped, relationship
from sqlmodel import Field, SQLModel
class JobPayment(SQLModel, table=True):
@@ -23,8 +22,8 @@ class JobPayment(SQLModel, table=True):
# Payment details
amount: float = Field(sa_column=Column(Numeric(20, 8), nullable=False))
currency: str = Field(default="AITBC", max_length=10)
status: PaymentStatus = Field(default=PaymentStatus.PENDING)
payment_method: PaymentMethod = Field(default=PaymentMethod.AITBC_TOKEN)
status: str = Field(default="pending", max_length=20)
payment_method: str = Field(default="aitbc_token", max_length=20)
# Addresses
escrow_address: Optional[str] = Field(default=None, max_length=100)
@@ -43,10 +42,10 @@ class JobPayment(SQLModel, table=True):
expires_at: Optional[datetime] = None
# Additional metadata
metadata: Optional[dict] = Field(default=None)
meta_data: Optional[dict] = Field(default=None, sa_column=Column(JSON))
# Relationships
jobs: List["Job"] = Relationship(back_populates="payment")
# jobs: Mapped[List["Job"]] = relationship(back_populates="payment")
class PaymentEscrow(SQLModel, table=True):

View File

@@ -6,13 +6,6 @@ from sqlmodel import SQLModel, Field, Relationship, Column
from sqlalchemy import JSON
from datetime import datetime
from typing import Optional, List
from enum import Enum
class UserStatus(str, Enum):
ACTIVE = "active"
INACTIVE = "inactive"
SUSPENDED = "suspended"
class User(SQLModel, table=True):
@@ -20,7 +13,7 @@ class User(SQLModel, table=True):
id: str = Field(primary_key=True)
email: str = Field(unique=True, index=True)
username: str = Field(unique=True, index=True)
status: UserStatus = Field(default=UserStatus.ACTIVE)
status: str = Field(default="active", max_length=20)
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
last_login: Optional[datetime] = None
@@ -44,28 +37,13 @@ class Wallet(SQLModel, table=True):
transactions: List["Transaction"] = Relationship(back_populates="wallet")
class TransactionType(str, Enum):
DEPOSIT = "deposit"
WITHDRAWAL = "withdrawal"
PURCHASE = "purchase"
REWARD = "reward"
REFUND = "refund"
class TransactionStatus(str, Enum):
PENDING = "pending"
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"
class Transaction(SQLModel, table=True):
"""Transaction model"""
id: str = Field(primary_key=True)
user_id: str = Field(foreign_key="user.id")
wallet_id: Optional[int] = Field(foreign_key="wallet.id")
type: TransactionType
status: TransactionStatus = Field(default=TransactionStatus.PENDING)
type: str = Field(max_length=20)
status: str = Field(default="pending", max_length=20)
amount: float
fee: float = Field(default=0.0)
description: Optional[str] = None

View File

@@ -56,6 +56,8 @@ from ..domain import (
MarketplaceBid,
User,
Wallet,
JobPayment,
PaymentEscrow,
)
# Service-specific models
@@ -101,4 +103,6 @@ __all__ = [
"LLMRequest",
"FFmpegRequest",
"BlenderRequest",
"JobPayment",
"PaymentEscrow",
]

View File

@@ -4,7 +4,7 @@ Service schemas for common GPU workloads
from typing import Any, Dict, List, Optional, Union
from enum import Enum
from pydantic import BaseModel, Field, validator
from pydantic import BaseModel, Field, field_validator
import re
@@ -123,7 +123,8 @@ class StableDiffusionRequest(BaseModel):
lora: Optional[str] = Field(None, description="LoRA model to use")
lora_scale: float = Field(1.0, ge=0.0, le=2.0, description="LoRA strength")
@validator('seed')
@field_validator('seed')
@classmethod
def validate_seed(cls, v):
if v is not None and isinstance(v, list):
if len(v) > 4:
@@ -289,9 +290,10 @@ class BlenderRequest(BaseModel):
transparent: bool = Field(False, description="Transparent background")
custom_args: Optional[List[str]] = Field(None, description="Custom Blender arguments")
@validator('frame_end')
def validate_frame_range(cls, v, values):
if 'frame_start' in values and v < values['frame_start']:
@field_validator('frame_end')
@classmethod
def validate_frame_range(cls, v, info):
if info and info.data and 'frame_start' in info.data and v < info.data['frame_start']:
raise ValueError("frame_end must be >= frame_start")
return v

View File

@@ -1,8 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException, status
from ..deps import require_client_key
from ..schemas import JobCreate, JobView, JobResult
from ..schemas.payments import JobPaymentCreate, PaymentMethod
from ..schemas import JobCreate, JobView, JobResult, JobPaymentCreate
from ..types import JobState
from ..services import JobService
from ..services.payments import PaymentService
@@ -27,11 +26,11 @@ async def submit_job(
job_id=job.id,
amount=req.payment_amount,
currency=req.payment_currency,
payment_method=PaymentMethod.AITBC_TOKEN # Jobs use AITBC tokens
payment_method="aitbc_token" # Jobs use AITBC tokens
)
payment = await payment_service.create_payment(job.id, payment_create)
job.payment_id = payment.id
job.payment_status = payment.status.value
job.payment_status = payment.status
session.commit()
session.refresh(job)

View File

@@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from ..deps import require_admin_key
from ..domain import MarketplaceOffer, Miner, OfferStatus
from ..domain import MarketplaceOffer, Miner
from ..schemas import MarketplaceOfferView
from ..storage import SessionDep

View File

@@ -1,5 +1,6 @@
from datetime import datetime
from typing import Any
import logging
from fastapi import APIRouter, Depends, HTTPException, Response, status
@@ -9,6 +10,8 @@ from ..services import JobService, MinerService
from ..services.receipts import ReceiptService
from ..storage import SessionDep
logger = logging.getLogger(__name__)
router = APIRouter(tags=["miner"])
@@ -78,6 +81,23 @@ async def submit_result(
job.receipt_id = receipt["receipt_id"] if receipt else None
session.add(job)
session.commit()
# Auto-release payment if job has payment
if job.payment_id and job.payment_status == "escrowed":
from ..services.payments import PaymentService
payment_service = PaymentService(session)
success = await payment_service.release_payment(
job.id,
job.payment_id,
reason="Job completed successfully"
)
if success:
job.payment_status = "released"
session.commit()
logger.info(f"Auto-released payment {job.payment_id} for completed job {job.id}")
else:
logger.error(f"Failed to auto-release payment {job.payment_id} for job {job.id}")
miner_service.release(
miner_id,
success=True,
@@ -106,5 +126,22 @@ async def submit_failure(
job.assigned_miner_id = miner_id
session.add(job)
session.commit()
# Auto-refund payment if job has payment
if job.payment_id and job.payment_status in ["pending", "escrowed"]:
from ..services.payments import PaymentService
payment_service = PaymentService(session)
success = await payment_service.refund_payment(
job.id,
job.payment_id,
reason=f"Job failed: {req.error_code}: {req.error_message}"
)
if success:
job.payment_status = "refunded"
session.commit()
logger.info(f"Auto-refunded payment {job.payment_id} for failed job {job.id}")
else:
logger.error(f"Failed to auto-refund payment {job.payment_id} for job {job.id}")
miner_service.release(miner_id, success=False)
return {"status": "ok"}

View File

@@ -37,7 +37,7 @@ class PartnerResponse(BaseModel):
class WebhookCreate(BaseModel):
"""Create a webhook subscription"""
url: str = Field(..., pattern=r'^https?://')
events: List[str] = Field(..., min_items=1)
events: List[str] = Field(..., min_length=1)
secret: Optional[str] = Field(max_length=100)

View File

@@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from typing import List
from ..deps import require_client_key
from ..schemas.payments import (
from ..schemas import (
JobPaymentCreate,
JobPaymentView,
PaymentRequest,

View File

@@ -3,12 +3,75 @@ from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, Optional, List
from base64 import b64encode, b64decode
from enum import Enum
from pydantic import BaseModel, Field, ConfigDict
from .types import JobState, Constraints
# Payment schemas
class JobPaymentCreate(BaseModel):
"""Request to create a payment for a job"""
job_id: str
amount: float
currency: str = "AITBC" # Jobs paid with AITBC tokens
payment_method: str = "aitbc_token" # Primary method for job payments
escrow_timeout_seconds: int = 3600 # 1 hour default
class JobPaymentView(BaseModel):
"""Payment information for a job"""
job_id: str
payment_id: str
amount: float
currency: str
status: str
payment_method: str
escrow_address: Optional[str] = None
refund_address: Optional[str] = None
created_at: datetime
updated_at: datetime
released_at: Optional[datetime] = None
refunded_at: Optional[datetime] = None
transaction_hash: Optional[str] = None
refund_transaction_hash: Optional[str] = None
class PaymentRequest(BaseModel):
"""Request to pay for a job"""
job_id: str
amount: float
currency: str = "BTC"
refund_address: Optional[str] = None
class PaymentReceipt(BaseModel):
"""Receipt for a payment"""
payment_id: str
job_id: str
amount: float
currency: str
status: str
transaction_hash: Optional[str] = None
created_at: datetime
verified_at: Optional[datetime] = None
class EscrowRelease(BaseModel):
"""Request to release escrow payment"""
job_id: str
payment_id: str
reason: Optional[str] = None
class RefundRequest(BaseModel):
"""Request to refund a payment"""
job_id: str
payment_id: str
reason: str
# User management schemas
class UserCreate(BaseModel):
email: str

View File

@@ -4,32 +4,16 @@ from __future__ import annotations
from datetime import datetime
from typing import Optional, Dict, Any
from enum import Enum
from pydantic import BaseModel, Field
class PaymentStatus(str, Enum):
"""Payment status values"""
PENDING = "pending"
ESCROWED = "escrowed"
RELEASED = "released"
REFUNDED = "refunded"
FAILED = "failed"
class PaymentMethod(str, Enum):
"""Payment methods"""
AITBC_TOKEN = "aitbc_token" # Primary method for job payments
BITCOIN = "bitcoin" # Only for exchange purchases
class JobPaymentCreate(BaseModel):
"""Request to create a payment for a job"""
job_id: str
amount: float
currency: str = "AITBC" # Jobs paid with AITBC tokens
payment_method: PaymentMethod = PaymentMethod.AITBC_TOKEN
payment_method: str = "aitbc_token" # Primary method for job payments
escrow_timeout_seconds: int = 3600 # 1 hour default
@@ -39,8 +23,8 @@ class JobPaymentView(BaseModel):
payment_id: str
amount: float
currency: str
status: PaymentStatus
payment_method: PaymentMethod
status: str
payment_method: str
escrow_address: Optional[str] = None
refund_address: Optional[str] = None
created_at: datetime
@@ -65,7 +49,7 @@ class PaymentReceipt(BaseModel):
job_id: str
amount: float
currency: str
status: PaymentStatus
status: str
transaction_hash: Optional[str] = None
created_at: datetime
verified_at: Optional[datetime] = None

View File

@@ -32,15 +32,8 @@ class JobService:
# Create payment if amount is specified
if req.payment_amount and req.payment_amount > 0:
from ..schemas.payments import JobPaymentCreate, PaymentMethod
payment_create = JobPaymentCreate(
job_id=job.id,
amount=req.payment_amount,
currency=req.payment_currency,
payment_method=PaymentMethod.BITCOIN
)
# Note: This is async, so we'll handle it in the router
job.payment_pending = True
# Note: Payment creation is handled in the router
pass
return job
@@ -81,6 +74,8 @@ class JobService:
requested_at=job.requested_at,
expires_at=job.expires_at,
error=job.error,
payment_id=job.payment_id,
payment_status=job.payment_status,
)
def to_result(self, job: Job) -> JobResult:

View File

@@ -5,7 +5,7 @@ from typing import Iterable, Optional
from sqlmodel import Session, select
from ..domain import MarketplaceOffer, MarketplaceBid, OfferStatus
from ..domain import MarketplaceOffer, MarketplaceBid
from ..schemas import (
MarketplaceBidRequest,
MarketplaceOfferView,
@@ -62,7 +62,7 @@ class MarketplaceService:
def get_stats(self) -> MarketplaceStatsView:
offers = self.session.exec(select(MarketplaceOffer)).all()
open_offers = [offer for offer in offers if offer.status == OfferStatus.open]
open_offers = [offer for offer in offers if offer.status == "open"]
total_offers = len(offers)
open_capacity = sum(offer.capacity for offer in open_offers)

View File

@@ -6,11 +6,9 @@ import httpx
import logging
from ..domain.payment import JobPayment, PaymentEscrow
from ..schemas.payments import (
from ..schemas import (
JobPaymentCreate,
JobPaymentView,
PaymentStatus,
PaymentMethod,
EscrowRelease,
RefundRequest
)
@@ -44,10 +42,10 @@ class PaymentService:
self.session.refresh(payment)
# For AITBC token payments, use token escrow
if payment_data.payment_method == PaymentMethod.AITBC_TOKEN:
if payment_data.payment_method == "aitbc_token":
await self._create_token_escrow(payment)
# Bitcoin payments only for exchange purchases
elif payment_data.payment_method == PaymentMethod.BITCOIN:
elif payment_data.payment_method == "bitcoin":
await self._create_bitcoin_escrow(payment)
return payment
@@ -61,7 +59,7 @@ class PaymentService:
response = await client.post(
f"{self.exchange_base_url}/api/v1/token/escrow/create",
json={
"amount": payment.amount,
"amount": float(payment.amount),
"currency": payment.currency,
"job_id": payment.job_id,
"timeout_seconds": 3600 # 1 hour
@@ -71,7 +69,7 @@ class PaymentService:
if response.status_code == 200:
escrow_data = response.json()
payment.escrow_address = escrow_data.get("escrow_id")
payment.status = PaymentStatus.ESCROWED
payment.status = "escrowed"
payment.escrowed_at = datetime.utcnow()
payment.updated_at = datetime.utcnow()
@@ -92,7 +90,7 @@ class PaymentService:
except Exception as e:
logger.error(f"Error creating token escrow: {e}")
payment.status = PaymentStatus.FAILED
payment.status = "failed"
payment.updated_at = datetime.utcnow()
self.session.commit()
@@ -104,7 +102,7 @@ class PaymentService:
response = await client.post(
f"{self.wallet_base_url}/api/v1/escrow/create",
json={
"amount": payment.amount,
"amount": float(payment.amount),
"currency": payment.currency,
"timeout_seconds": 3600 # 1 hour
}
@@ -113,7 +111,7 @@ class PaymentService:
if response.status_code == 200:
escrow_data = response.json()
payment.escrow_address = escrow_data["address"]
payment.status = PaymentStatus.ESCROWED
payment.status = "escrowed"
payment.escrowed_at = datetime.utcnow()
payment.updated_at = datetime.utcnow()
@@ -134,7 +132,7 @@ class PaymentService:
except Exception as e:
logger.error(f"Error creating Bitcoin escrow: {e}")
payment.status = PaymentStatus.FAILED
payment.status = "failed"
payment.updated_at = datetime.utcnow()
self.session.commit()
@@ -145,7 +143,7 @@ class PaymentService:
if not payment or payment.job_id != job_id:
return False
if payment.status != PaymentStatus.ESCROWED:
if payment.status != "escrowed":
return False
try:
@@ -161,7 +159,7 @@ class PaymentService:
if response.status_code == 200:
release_data = response.json()
payment.status = PaymentStatus.RELEASED
payment.status = "released"
payment.released_at = datetime.utcnow()
payment.updated_at = datetime.utcnow()
payment.transaction_hash = release_data.get("transaction_hash")
@@ -195,7 +193,7 @@ class PaymentService:
if not payment or payment.job_id != job_id:
return False
if payment.status not in [PaymentStatus.ESCROWED, PaymentStatus.PENDING]:
if payment.status not in ["escrowed", "pending"]:
return False
try:
@@ -206,14 +204,14 @@ class PaymentService:
json={
"payment_id": payment_id,
"address": payment.refund_address,
"amount": payment.amount,
"amount": float(payment.amount),
"reason": reason
}
)
if response.status_code == 200:
refund_data = response.json()
payment.status = PaymentStatus.REFUNDED
payment.status = "refunded"
payment.refunded_at = datetime.utcnow()
payment.updated_at = datetime.utcnow()
payment.refund_transaction_hash = refund_data.get("transaction_hash")

View File

@@ -8,7 +8,7 @@ from sqlalchemy.engine import Engine
from sqlmodel import Session, SQLModel, create_engine
from ..config import settings
from ..domain import Job, Miner, MarketplaceOffer, MarketplaceBid
from ..domain import Job, Miner, MarketplaceOffer, MarketplaceBid, JobPayment, PaymentEscrow
from .models_governance import GovernanceProposal, ProposalVote, TreasuryTransaction, GovernanceParameter
_engine: Engine | None = None

View File

@@ -6,6 +6,7 @@ from sqlmodel import SQLModel, Field, Relationship, Column, JSON
from typing import Optional, Dict, Any
from datetime import datetime
from uuid import uuid4
from pydantic import ConfigDict
class GovernanceProposal(SQLModel, table=True):
@@ -83,10 +84,11 @@ class VotingPowerSnapshot(SQLModel, table=True):
snapshot_time: datetime = Field(default_factory=datetime.utcnow, index=True)
block_number: Optional[int] = Field(index=True)
class Config:
indexes = [
model_config = ConfigDict(
indexes=[
{"name": "ix_user_snapshot", "fields": ["user_id", "snapshot_time"]},
]
)
class ProtocolUpgrade(SQLModel, table=True):