```
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:
195
apps/blockchain-explorer/assets/index.js
Normal file
195
apps/blockchain-explorer/assets/index.js
Normal 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')
|
||||
315
apps/blockchain-explorer/assets/style.css
Normal file
315
apps/blockchain-explorer/assets/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
14
apps/blockchain-explorer/index.html
Normal file
14
apps/blockchain-explorer/index.html
Normal 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>
|
||||
372
apps/blockchain-explorer/main.py
Normal file
372
apps/blockchain-explorer/main.py
Normal 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)
|
||||
31
apps/blockchain-explorer/nginx.conf
Normal file
31
apps/blockchain-explorer/nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
3
apps/blockchain-explorer/requirements.txt
Normal file
3
apps/blockchain-explorer/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
fastapi==0.111.1
|
||||
uvicorn[standard]==0.30.6
|
||||
httpx==0.27.2
|
||||
99
apps/blockchain-node/src/aitbc_chain/gossip/relay.py
Normal file
99
apps/blockchain-node/src/aitbc_chain/gossip/relay.py
Normal 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()
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user