Based on the repository's commit message style and the changes in the diff, here's an appropriate commit message:

```
feat: add websocket tests, PoA metrics, marketplace endpoints, and enhanced observability

- Add comprehensive websocket tests for blocks and transactions streams including multi-subscriber and high-volume scenarios
- Extend PoA consensus with per-proposer block metrics and rotation tracking
- Add latest block interval gauge and RPC error spike alerting
- Enhance mock coordinator
This commit is contained in:
oib
2025-12-22 07:55:09 +01:00
parent fb60505cdf
commit d98b2c7772
70 changed files with 3472 additions and 246 deletions

View File

@ -32,3 +32,6 @@ jobs:
run: |
chmod +x scripts/ci/run_python_tests.sh
./scripts/ci/run_python_tests.sh
- name: Run blockchain-node websocket tests
run: |
poetry run pytest apps/blockchain-node/tests/test_websocket.py

View File

@ -0,0 +1,6 @@
---
description: do the git thing
auto_execution_mode: 3
---
do the git thing

View File

@ -1,6 +0,0 @@
---
description: docs/roadmap.md
auto_execution_mode: 3
---
Check docs/roadmap.md and carry out the next recommended step.

View File

@ -0,0 +1,7 @@
---
description: run script and debug
auto_execution_mode: 3
---
run script and debug
rerun script

View File

@ -0,0 +1,6 @@
---
description: still same issue
auto_execution_mode: 3
---
still same issue

View File

@ -0,0 +1,23 @@
{
"accounts": [
{
"address": "ait1faucet000000000000000000000000000000000",
"balance": 1000000000,
"nonce": 0
}
],
"authorities": [
{
"address": "ait1devproposer000000000000000000000000000000",
"weight": 1
}
],
"chain_id": "ait-devnet",
"params": {
"base_fee": 10,
"coordinator_ratio": 0.05,
"fee_per_byte": 1,
"mint_per_unit": 1000
},
"timestamp": 1766383019
}

View File

@ -8,7 +8,7 @@ This directory contains Prometheus and Grafana assets for the devnet environment
## Files
- `prometheus.yml` Scrapes both blockchain node and mock coordinator/miner metrics.
- `grafana-dashboard.json` Panels for block interval, RPC throughput, miner activity, coordinator receipt flow, **plus new gossip queue, subscriber, and publication rate panels**.
- `grafana-dashboard.json` Panels for block interval (including latest interval gauge), RPC throughput, miner activity, coordinator receipt flow, gossip queue/subscriber/publication metrics, and PoA proposer visibility (rotation counts, blocks proposed per proposer).
- `alerts.yml` Alertmanager rules highlighting proposer stalls, miner errors, and coordinator receipt drop-offs.
- `gossip-recording-rules.yml` Prometheus recording rules that derive queue/subscriber gauges and publication rates from gossip metrics.

View File

@ -41,3 +41,13 @@ groups:
summary: "No receipts attested in 5 minutes"
description: |
Receipt attestations ceased during the last five minutes. Inspect coordinator connectivity.
- alert: RpcErrorsSpiking
expr: increase(rpc_request_failures_total[5m]) > 0
for: 5m
labels:
severity: warning
annotations:
summary: "RPC error spike detected"
description: |
RPC request failures have increased during the last five minutes. Investigate rpc_request_failures_total for details.

View File

@ -0,0 +1,255 @@
{
"annotations": {
"list": []
},
"editable": true,
"panels": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 0
},
"id": 1,
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "rate(blockchain_block_height[1m])",
"refId": "A"
}
],
"title": "Block Production Interval (seconds)",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 0
},
"id": 2,
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "avg_over_time(mempool_queue_depth[1m])",
"refId": "A"
}
],
"title": "Mempool Queue Depth",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 8
},
"id": 3,
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "avg_over_time(miner_queue_depth[1m])",
"refId": "A"
}
],
"title": "Miner Queue Depth",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 8
},
"id": 4,
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "avg_over_time(miner_job_duration_seconds_sum[1m]) / avg_over_time(miner_job_duration_seconds_count[1m])",
"refId": "A"
}
],
"title": "Miner Job Duration Seconds",
"type": "timeseries"
}
],
"refresh": "10s",
"schemaVersion": 38,
"style": "dark",
"tags": [
"aitbc",
"blockchain"
],
"templating": {
"list": []
},
"time": {
"from": "now-5m",
"to": "now"
},
"timepicker": {},
"timezone": "",
"title": "AITBC Blockchain Node",
"uid": "aitbc-node",
"version": 1
}

View File

@ -0,0 +1,322 @@
{
"annotations": {
"list": []
},
"editable": true,
"panels": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 0
},
"id": 1,
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "rate(coordinator_jobs_submitted_total[1m])",
"refId": "A"
}
],
"title": "Jobs Submitted",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 0
},
"id": 2,
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "rate(coordinator_jobs_completed_total[1m])",
"refId": "A"
}
],
"title": "Jobs Completed",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 8
},
"id": 3,
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "rate(coordinator_jobs_failed_total[1m])",
"refId": "A"
}
],
"title": "Jobs Failed",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "orange",
"value": 5
},
{
"color": "red",
"value": 10
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 6,
"x": 12,
"y": 8
},
"id": 4,
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "auto",
"orientation": "horizontal",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto"
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "miner_active_jobs",
"refId": "A"
}
],
"title": "Active Jobs",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "orange",
"value": 5
},
{
"color": "red",
"value": 10
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 6,
"x": 18,
"y": 8
},
"id": 5,
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "auto",
"orientation": "horizontal",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto"
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "miner_error_rate",
"refId": "A"
}
],
"title": "Miner Error Rate",
"type": "stat"
}
],
"refresh": "10s",
"schemaVersion": 38,
"style": "dark",
"tags": [
"aitbc",
"coordinator"
],
"templating": {
"list": []
},
"time": {
"from": "now-5m",
"to": "now"
},
"timepicker": {},
"timezone": "",
"title": "AITBC Coordinator Overview",
"uid": "aitbc-coordinator",
"version": 1
}

View File

@ -352,6 +352,80 @@
],
"title": "Gossip Publication Rate by Topic",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "PROMETHEUS_DS"
},
"fieldConfig": {
"defaults": {
"custom": {},
"unit": "none"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 32
},
"id": 9,
"options": {
"legend": {
"calcs": ["lastNotNull"],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
}
},
"targets": [
{
"expr": "increase(poa_proposer_rotations_total[30m])",
"legendFormat": "rotations (30m)",
"refId": "A"
}
],
"title": "Proposer Rotation Count",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "PROMETHEUS_DS"
},
"fieldConfig": {
"defaults": {
"custom": {},
"unit": "ops"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 32
},
"id": 10,
"options": {
"legend": {
"calcs": ["lastNotNull"],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
}
},
"targets": [
{
"expr": "label_replace(sum(rate({__name__=~\"poa_blocks_proposed_total_.*\"}[5m])) by (__name__), \"proposer\", \"$1\", \"__name__\", \"poa_blocks_proposed_total_(.*)\")",
"legendFormat": "{{proposer}}",
"refId": "A"
}
],
"title": "Blocks Proposed per Proposer (5m rate)",
"type": "timeseries"
}
],
"refresh": "10s",

0
apps/blockchain-node/scripts/devnet_up.sh Normal file → Executable file
View File

View File

@ -3,9 +3,11 @@
from __future__ import annotations
import asyncio
import contextlib
import random
import time
from typing import Dict
from collections import deque
from typing import Deque, Dict, List
from fastapi import FastAPI
from fastapi.responses import PlainTextResponse
@ -14,21 +16,78 @@ from aitbc_chain.metrics import metrics_registry
app = FastAPI(title="Mock Coordinator API", version="0.1.0")
SIMULATED_MINERS: List[str] = ["miner-alpha", "miner-beta", "miner-gamma"]
SIMULATED_CLIENTS: List[str] = ["client-labs", "client-trading", "client-research"]
MOCK_JOBS: Dict[str, Dict[str, str]] = {
"job_1": {"status": "complete", "price": "50000", "compute_units": 2500},
"job_2": {"status": "complete", "price": "25000", "compute_units": 1200},
}
_simulation_task: asyncio.Task | None = None
_job_rollup: Deque[str] = deque(maxlen=120)
def _simulate_miner_metrics() -> None:
metrics_registry.set_gauge("miner_active_jobs", float(random.randint(0, 5)))
active_jobs = random.randint(1, 6)
metrics_registry.set_gauge("miner_active_jobs", float(active_jobs))
metrics_registry.set_gauge("miner_error_rate", float(random.randint(0, 1)))
metrics_registry.observe("miner_job_duration_seconds", random.uniform(1.0, 5.0))
metrics_registry.observe("miner_job_duration_seconds", random.uniform(1.5, 8.0))
metrics_registry.observe("miner_queue_depth", float(random.randint(0, 12)))
async def _simulation_loop() -> None:
job_counter = 3
while True:
_simulate_miner_metrics()
job_id = f"job_{job_counter}"
client = random.choice(SIMULATED_CLIENTS)
miner = random.choice(SIMULATED_MINERS)
price = random.randint(15_000, 75_000)
compute_units = random.randint(750, 5000)
MOCK_JOBS[job_id] = {
"status": random.choice(["complete", "pending", "failed"]),
"price": str(price),
"compute_units": compute_units,
"client": client,
"assigned_miner": miner,
}
_job_rollup.append(job_id)
if len(MOCK_JOBS) > _job_rollup.maxlen:
oldest = _job_rollup.popleft()
MOCK_JOBS.pop(oldest, None)
metrics_registry.increment("coordinator_jobs_submitted_total")
metrics_registry.observe("coordinator_job_price", float(price))
metrics_registry.observe("coordinator_job_compute_units", float(compute_units))
if MOCK_JOBS[job_id]["status"] == "failed":
metrics_registry.increment("coordinator_jobs_failed_total")
else:
metrics_registry.increment("coordinator_jobs_completed_total")
job_counter += 1
await asyncio.sleep(random.uniform(1.5, 3.5))
@app.on_event("startup")
async def _startup() -> None:
global _simulation_task
_simulate_miner_metrics()
_simulation_task = asyncio.create_task(_simulation_loop())
@app.on_event("shutdown")
async def _shutdown() -> None:
global _simulation_task
if _simulation_task:
_simulation_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await _simulation_task
_simulation_task = None
@app.get("/health")

View File

@ -4,12 +4,21 @@ import asyncio
import hashlib
from dataclasses import dataclass
from datetime import datetime
import re
from typing import Callable, ContextManager, Optional
from sqlmodel import Session, select
from ..logging import get_logger
from ..metrics import metrics_registry
_METRIC_KEY_SANITIZE = re.compile(r"[^0-9a-zA-Z]+")
def _sanitize_metric_suffix(value: str) -> str:
sanitized = _METRIC_KEY_SANITIZE.sub("_", value).strip("_")
return sanitized or "unknown"
from ..models import Block
from ..gossip import gossip_broker
@ -33,6 +42,7 @@ class PoAProposer:
self._logger = get_logger(__name__)
self._stop_event = asyncio.Event()
self._task: Optional[asyncio.Task[None]] = None
self._last_proposer_id: Optional[str] = None
async def start(self) -> None:
if self._task is not None:
@ -104,6 +114,13 @@ class PoAProposer:
metrics_registry.set_gauge("chain_head_height", float(next_height))
if interval_seconds is not None and interval_seconds >= 0:
metrics_registry.observe("block_interval_seconds", interval_seconds)
metrics_registry.set_gauge("poa_last_block_interval_seconds", float(interval_seconds))
proposer_suffix = _sanitize_metric_suffix(self._config.proposer_id)
metrics_registry.increment(f"poa_blocks_proposed_total_{proposer_suffix}")
if self._last_proposer_id is not None and self._last_proposer_id != self._config.proposer_id:
metrics_registry.increment("poa_proposer_rotations_total")
self._last_proposer_id = self._config.proposer_id
asyncio.create_task(
gossip_broker.publish(

View File

@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import datetime
import re
from typing import List, Optional
from typing import Optional
from pydantic import field_validator
from sqlalchemy import Column
@ -34,8 +34,8 @@ class Block(SQLModel, table=True):
tx_count: int = 0
state_root: Optional[str] = None
transactions: List["Transaction"] = Relationship(back_populates="block")
receipts: List["Receipt"] = Relationship(back_populates="block")
transactions: list["Transaction"] = Relationship(back_populates="block")
receipts: list["Receipt"] = Relationship(back_populates="block")
@field_validator("hash", mode="before")
@classmethod
@ -69,7 +69,7 @@ class Transaction(SQLModel, table=True):
)
created_at: datetime = Field(default_factory=datetime.utcnow, index=True)
block: Optional[Block] = Relationship(back_populates="transactions")
block: Optional["Block"] = Relationship(back_populates="transactions")
@field_validator("tx_hash", mode="before")
@classmethod
@ -101,7 +101,7 @@ class Receipt(SQLModel, table=True):
minted_amount: Optional[int] = None
recorded_at: datetime = Field(default_factory=datetime.utcnow, index=True)
block: Optional[Block] = Relationship(back_populates="receipts")
block: Optional["Block"] = Relationship(back_populates="receipts")
@field_validator("receipt_id", mode="before")
@classmethod

View File

@ -0,0 +1,9 @@
"""Observability tooling for the AITBC blockchain node."""
from .dashboards import generate_default_dashboards
from .exporters import register_exporters
__all__ = [
"generate_default_dashboards",
"register_exporters",
]

View File

@ -0,0 +1,267 @@
"""Generate Grafana dashboards for the devnet observability stack."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Dict, Iterable
def _timeseries_panel(
panel_id: int,
title: str,
expr: str,
grid_x: int,
grid_y: int,
datasource_uid: str,
) -> Dict[str, object]:
return {
"datasource": {"type": "prometheus", "uid": datasource_uid},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "green", "value": None},
{"color": "red", "value": 80},
],
},
},
"overrides": [],
},
"gridPos": {"h": 8, "w": 12, "x": grid_x, "y": grid_y},
"id": panel_id,
"options": {
"legend": {"displayMode": "list", "placement": "bottom"},
"tooltip": {"mode": "multi", "sort": "none"},
},
"targets": [
{
"datasource": {"type": "prometheus", "uid": datasource_uid},
"expr": expr,
"refId": "A",
}
],
"title": title,
"type": "timeseries",
}
def _stat_panel(
panel_id: int,
title: str,
expr: str,
grid_x: int,
grid_y: int,
datasource_uid: str,
) -> Dict[str, object]:
return {
"datasource": {"type": "prometheus", "uid": datasource_uid},
"fieldConfig": {
"defaults": {
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "green", "value": None},
{"color": "orange", "value": 5},
{"color": "red", "value": 10},
],
},
},
"overrides": [],
},
"gridPos": {"h": 4, "w": 6, "x": grid_x, "y": grid_y},
"id": panel_id,
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "auto",
"orientation": "horizontal",
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": False},
"textMode": "auto",
},
"targets": [
{
"datasource": {"type": "prometheus", "uid": datasource_uid},
"expr": expr,
"refId": "A",
}
],
"title": title,
"type": "stat",
}
def _coordinator_dashboard(datasource_uid: str) -> Dict[str, object]:
return {
"uid": "aitbc-coordinator",
"title": "AITBC Coordinator Overview",
"editable": True,
"tags": ["aitbc", "coordinator"],
"timezone": "",
"schemaVersion": 38,
"version": 1,
"refresh": "10s",
"style": "dark",
"annotations": {"list": []},
"templating": {"list": []},
"time": {"from": "now-5m", "to": "now"},
"timepicker": {},
"panels": [
_timeseries_panel(
panel_id=1,
title="Jobs Submitted",
expr="rate(coordinator_jobs_submitted_total[1m])",
grid_x=0,
grid_y=0,
datasource_uid=datasource_uid,
),
_timeseries_panel(
panel_id=2,
title="Jobs Completed",
expr="rate(coordinator_jobs_completed_total[1m])",
grid_x=12,
grid_y=0,
datasource_uid=datasource_uid,
),
_timeseries_panel(
panel_id=3,
title="Jobs Failed",
expr="rate(coordinator_jobs_failed_total[1m])",
grid_x=0,
grid_y=8,
datasource_uid=datasource_uid,
),
_timeseries_panel(
panel_id=6,
title="Average Bid Price",
expr="avg_over_time(coordinator_job_price[5m])",
grid_x=12,
grid_y=8,
datasource_uid=datasource_uid,
),
_stat_panel(
panel_id=4,
title="Active Jobs",
expr="miner_active_jobs",
grid_x=0,
grid_y=16,
datasource_uid=datasource_uid,
),
_stat_panel(
panel_id=5,
title="Miner Error Rate",
expr="miner_error_rate",
grid_x=6,
grid_y=16,
datasource_uid=datasource_uid,
),
_stat_panel(
panel_id=7,
title="Avg Compute Units",
expr="avg_over_time(coordinator_job_compute_units[5m])",
grid_x=12,
grid_y=16,
datasource_uid=datasource_uid,
),
],
}
def _node_dashboard(datasource_uid: str) -> Dict[str, object]:
return {
"uid": "aitbc-node",
"title": "AITBC Blockchain Node",
"editable": True,
"tags": ["aitbc", "blockchain"],
"timezone": "",
"schemaVersion": 38,
"version": 1,
"refresh": "10s",
"style": "dark",
"annotations": {"list": []},
"templating": {"list": []},
"time": {"from": "now-5m", "to": "now"},
"timepicker": {},
"panels": [
_timeseries_panel(
panel_id=1,
title="Block Production Interval (seconds)",
expr="1 / rate(blockchain_block_height[1m])",
grid_x=0,
grid_y=0,
datasource_uid=datasource_uid,
),
_timeseries_panel(
panel_id=2,
title="Mempool Queue Depth",
expr="avg_over_time(mempool_queue_depth[1m])",
grid_x=12,
grid_y=0,
datasource_uid=datasource_uid,
),
_timeseries_panel(
panel_id=5,
title="Proposer Rotation Count",
expr="increase(poa_proposer_rotations_total[5m])",
grid_x=0,
grid_y=8,
datasource_uid=datasource_uid,
),
_timeseries_panel(
panel_id=3,
title="Miner Queue Depth",
expr="avg_over_time(miner_queue_depth[1m])",
grid_x=12,
grid_y=8,
datasource_uid=datasource_uid,
),
_timeseries_panel(
panel_id=4,
title="Miner Job Duration Seconds",
expr="avg_over_time(miner_job_duration_seconds_sum[1m]) / avg_over_time(miner_job_duration_seconds_count[1m])",
grid_x=0,
grid_y=16,
datasource_uid=datasource_uid,
),
_timeseries_panel(
panel_id=6,
title="RPC 95th Percentile Latency",
expr="histogram_quantile(0.95, sum(rate(rpc_request_duration_seconds_bucket[5m])) by (le))",
grid_x=12,
grid_y=16,
datasource_uid=datasource_uid,
),
],
}
def _dashboard_payloads(datasource_uid: str) -> Iterable[tuple[str, Dict[str, object]]]:
return (
("coordinator-overview.json", _coordinator_dashboard(datasource_uid)),
("blockchain-node-overview.json", _node_dashboard(datasource_uid)),
)
def generate_default_dashboards(output_dir: Path, datasource_uid: str = "${DS_PROMETHEUS}") -> None:
"""Write Grafana dashboard JSON exports to ``output_dir``.
Parameters
----------
output_dir:
Directory that will receive the generated JSON files. It is created if
it does not already exist.
datasource_uid:
Grafana datasource UID for Prometheus queries (defaults to the
built-in "${DS_PROMETHEUS}" variable).
"""
output_dir.mkdir(parents=True, exist_ok=True)
for filename, payload in _dashboard_payloads(datasource_uid):
dashboard_path = output_dir / filename
with dashboard_path.open("w", encoding="utf-8") as fp:
json.dump(payload, fp, indent=2, sort_keys=True)

View File

@ -0,0 +1,17 @@
"""Placeholder exporter registration for metrics/log sinks."""
from __future__ import annotations
from typing import Iterable
REGISTERED_EXPORTERS: list[str] = []
def register_exporters(exporters: Iterable[str]) -> None:
"""Attach exporters for observability pipelines.
Real implementations might wire Prometheus registrations, log shippers,
or tracing exporters. For now, we simply record the names to keep track
of requested sinks.
"""
REGISTERED_EXPORTERS.extend(exporters)

View File

@ -1,8 +1,15 @@
from __future__ import annotations
import sys
from pathlib import Path
import pytest
from sqlmodel import SQLModel, Session, create_engine
PROJECT_ROOT = Path(__file__).resolve().parent.parent / "src"
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from aitbc_chain.models import Block, Transaction, Receipt # noqa: F401 - ensure models imported for metadata

View File

@ -0,0 +1,39 @@
"""Tests for the observability dashboard helpers."""
from __future__ import annotations
import json
from pathlib import Path
from aitbc_chain.observability.dashboards import generate_default_dashboards
from aitbc_chain.observability import exporters
def test_generate_default_dashboards_creates_files(tmp_path: Path) -> None:
output_dir = tmp_path / "dashboards"
generate_default_dashboards(output_dir, datasource_uid="prometheus")
expected_files = {
"blockchain-node-overview.json",
"coordinator-overview.json",
}
actual_files = {path.name for path in output_dir.glob("*.json")}
assert actual_files == expected_files
for file_path in output_dir.glob("*.json"):
with file_path.open("r", encoding="utf-8") as handle:
payload = json.load(handle)
assert payload["uid"] in {"aitbc-coordinator", "aitbc-node"}
assert payload["title"].startswith("AITBC")
assert payload["panels"], "Dashboard should contain at least one panel"
def test_register_exporters_tracks_names() -> None:
exporters.REGISTERED_EXPORTERS.clear()
exporters.register_exporters(["prometheus", "loki"])
assert exporters.REGISTERED_EXPORTERS == ["prometheus", "loki"]

View File

@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
from contextlib import ExitStack
from fastapi.testclient import TestClient
@ -10,8 +11,6 @@ from aitbc_chain.gossip import gossip_broker
def _publish(topic: str, message: dict) -> None:
asyncio.run(gossip_broker.publish(topic, message))
def test_blocks_websocket_stream() -> None:
client = TestClient(create_app())
@ -28,19 +27,100 @@ def test_blocks_websocket_stream() -> None:
assert message == payload
def test_transactions_websocket_stream() -> None:
def test_blocks_websocket_multiple_subscribers_receive_all_payloads() -> None:
with TestClient(create_app()) as client, ExitStack() as stack:
sockets = [
stack.enter_context(client.websocket_connect("/rpc/ws/blocks"))
for _ in range(3)
]
payloads = [
{
"height": height,
"hash": "0x" + f"{height:064x}",
"parent_hash": (
"0x" + f"{height - 1:064x}" if height > 0 else "0x" + "0" * 64
),
"timestamp": f"2025-01-01T00:00:{height:02d}Z",
"tx_count": height % 3,
}
for height in range(5)
]
for payload in payloads:
_publish("blocks", payload)
for socket in sockets:
received = [socket.receive_json() for _ in payloads]
assert received == payloads
# Publish another payload to ensure subscribers continue receiving in order.
final_payload = {
"height": 99,
"hash": "0x" + "f" * 64,
"parent_hash": "0x" + "e" * 64,
"timestamp": "2025-01-01T00:01:39Z",
"tx_count": 5,
}
_publish("blocks", final_payload)
for socket in sockets:
assert socket.receive_json() == final_payload
def test_blocks_websocket_high_volume_load() -> None:
message_count = 40
subscriber_count = 4
with TestClient(create_app()) as client, ExitStack() as stack:
sockets = [
stack.enter_context(client.websocket_connect("/rpc/ws/blocks"))
for _ in range(subscriber_count)
]
payloads = []
for height in range(message_count):
payload = {
"height": height,
"hash": "0x" + f"{height + 100:064x}",
"parent_hash": "0x" + f"{height + 99:064x}" if height > 0 else "0x" + "0" * 64,
"timestamp": f"2025-01-01T00:{height // 60:02d}:{height % 60:02d}Z",
"tx_count": height % 7,
}
payloads.append(payload)
_publish("blocks", payload)
for socket in sockets:
received = [socket.receive_json() for _ in payloads]
assert received == payloads
def test_transactions_websocket_cleans_up_on_disconnect() -> None:
client = TestClient(create_app())
with client.websocket_connect("/rpc/ws/transactions") as websocket:
payload = {
"tx_hash": "0x" + "a" * 64,
"tx_hash": "0x" + "b" * 64,
"sender": "alice",
"recipient": "bob",
"payload": {"amount": 1},
"nonce": 1,
"fee": 0,
"recipient": "carol",
"payload": {"amount": 2},
"nonce": 7,
"fee": 1,
"type": "TRANSFER",
}
_publish("transactions", payload)
message = websocket.receive_json()
assert message == payload
assert websocket.receive_json() == payload
# After closing the websocket, publishing again should not raise and should not hang.
_publish(
"transactions",
{
"tx_hash": "0x" + "c" * 64,
"sender": "alice",
"recipient": "dave",
"payload": {"amount": 3},
"nonce": 8,
"fee": 1,
"type": "TRANSFER",
},
)

View File

@ -4,6 +4,27 @@
FastAPI service that accepts client compute jobs, matches miners, and tracks job lifecycle for the AITBC network.
## Marketplace Extensions
Stage 2 introduces public marketplace endpoints exposed under `/v1/marketplace`:
- `GET /v1/marketplace/offers` list available provider offers (filterable by status).
- `GET /v1/marketplace/stats` aggregated supply/demand metrics surfaced in the marketplace web dashboard.
- `POST /v1/marketplace/bids` accept bid submissions for matching (mock-friendly; returns `202 Accepted`).
These endpoints serve the `apps/marketplace-web/` dashboard via `VITE_MARKETPLACE_DATA_MODE=live`.
## Explorer Endpoints
The coordinator now exposes read-only explorer data under `/v1/explorer` for `apps/explorer-web/` live mode:
- `GET /v1/explorer/blocks` block summaries derived from recent job activity.
- `GET /v1/explorer/transactions` transaction-like records for coordinator jobs.
- `GET /v1/explorer/addresses` aggregated address activity and balances.
- `GET /v1/explorer/receipts` latest job receipts (filterable by `job_id`).
Set `VITE_DATA_MODE=live` and `VITE_COORDINATOR_API` in the explorer web app to consume these APIs.
## Development Setup
1. Create a virtual environment in `apps/coordinator-api/.venv`.

View File

@ -3,5 +3,13 @@
from .job import Job
from .miner import Miner
from .job_receipt import JobReceipt
from .marketplace import MarketplaceOffer, MarketplaceBid, OfferStatus
__all__ = ["Job", "Miner", "JobReceipt"]
__all__ = [
"Job",
"Miner",
"JobReceipt",
"MarketplaceOffer",
"MarketplaceBid",
"OfferStatus",
]

View File

@ -0,0 +1,36 @@
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 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))
created_at: datetime = Field(default_factory=datetime.utcnow, nullable=False, index=True)
attributes: dict = Field(default_factory=dict, sa_column=Column(JSON, nullable=False))
class MarketplaceBid(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)
notes: Optional[str] = Field(default=None)
status: str = Field(default="pending", nullable=False)
submitted_at: datetime = Field(default_factory=datetime.utcnow, nullable=False, index=True)

View File

@ -2,7 +2,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .config import settings
from .routers import client, miner, admin
from .routers import client, miner, admin, marketplace, explorer
def create_app() -> FastAPI:
@ -20,9 +20,11 @@ def create_app() -> FastAPI:
allow_headers=["*"]
)
app.include_router(client.router, prefix="/v1")
app.include_router(miner.router, prefix="/v1")
app.include_router(admin.router, prefix="/v1")
app.include_router(client, prefix="/v1")
app.include_router(miner, prefix="/v1")
app.include_router(admin, prefix="/v1")
app.include_router(marketplace, prefix="/v1")
app.include_router(explorer, prefix="/v1")
@app.get("/v1/health", tags=["health"], summary="Service healthcheck")
async def health() -> dict[str, str]:

View File

@ -4,7 +4,7 @@ from datetime import datetime
from enum import Enum
from typing import Any, Dict, Optional
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, ConfigDict
class JobState(str, Enum):
@ -76,3 +76,97 @@ class JobFailSubmit(BaseModel):
error_code: str
error_message: str
metrics: Dict[str, Any] = Field(default_factory=dict)
class MarketplaceOfferView(BaseModel):
id: str
provider: str
capacity: int
price: float
sla: str
status: str
created_at: datetime
class MarketplaceStatsView(BaseModel):
totalOffers: int
openCapacity: int
averagePrice: float
activeBids: int
class MarketplaceBidRequest(BaseModel):
provider: str = Field(..., min_length=1)
capacity: int = Field(..., gt=0)
price: float = Field(..., gt=0)
notes: Optional[str] = Field(default=None, max_length=1024)
class BlockSummary(BaseModel):
model_config = ConfigDict(populate_by_name=True)
height: int
hash: str
timestamp: datetime
txCount: int
proposer: str
class BlockListResponse(BaseModel):
model_config = ConfigDict(populate_by_name=True)
items: list[BlockSummary]
next_offset: Optional[str | int] = None
class TransactionSummary(BaseModel):
model_config = ConfigDict(populate_by_name=True, ser_json_tuples=True)
hash: str
block: str | int
from_address: str = Field(alias="from")
to_address: Optional[str] = Field(default=None, alias="to")
value: str
status: str
class TransactionListResponse(BaseModel):
model_config = ConfigDict(populate_by_name=True)
items: list[TransactionSummary]
next_offset: Optional[str | int] = None
class AddressSummary(BaseModel):
model_config = ConfigDict(populate_by_name=True)
address: str
balance: str
txCount: int
lastActive: datetime
recentTransactions: Optional[list[str]] = Field(default=None)
class AddressListResponse(BaseModel):
model_config = ConfigDict(populate_by_name=True)
items: list[AddressSummary]
next_offset: Optional[str | int] = None
class ReceiptSummary(BaseModel):
model_config = ConfigDict(populate_by_name=True)
receiptId: str
miner: str
coordinator: str
issuedAt: datetime
status: str
payload: Optional[Dict[str, Any]] = None
class ReceiptListResponse(BaseModel):
model_config = ConfigDict(populate_by_name=True)
jobId: str
items: list[ReceiptSummary]

View File

@ -1 +1,9 @@
"""Router modules for the coordinator API."""
from .client import router as client
from .miner import router as miner
from .admin import router as admin
from .marketplace import router as marketplace
from .explorer import router as explorer
__all__ = ["client", "miner", "admin", "marketplace", "explorer"]

View File

@ -0,0 +1,63 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Query
from ..models import (
BlockListResponse,
TransactionListResponse,
AddressListResponse,
ReceiptListResponse,
)
from ..services import ExplorerService
from ..storage import SessionDep
router = APIRouter(prefix="/explorer", tags=["explorer"])
def _service(session: SessionDep) -> ExplorerService:
return ExplorerService(session)
@router.get("/blocks", response_model=BlockListResponse, summary="List recent blocks")
async def list_blocks(
*,
session: SessionDep,
limit: int = Query(default=20, ge=1, le=200),
offset: int = Query(default=0, ge=0),
) -> BlockListResponse:
return _service(session).list_blocks(limit=limit, offset=offset)
@router.get(
"/transactions",
response_model=TransactionListResponse,
summary="List recent transactions",
)
async def list_transactions(
*,
session: SessionDep,
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
) -> TransactionListResponse:
return _service(session).list_transactions(limit=limit, offset=offset)
@router.get("/addresses", response_model=AddressListResponse, summary="List address summaries")
async def list_addresses(
*,
session: SessionDep,
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
) -> AddressListResponse:
return _service(session).list_addresses(limit=limit, offset=offset)
@router.get("/receipts", response_model=ReceiptListResponse, summary="List job receipts")
async def list_receipts(
*,
session: SessionDep,
job_id: str | None = Query(default=None, description="Filter by job identifier"),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
) -> ReceiptListResponse:
return _service(session).list_receipts(job_id=job_id, limit=limit, offset=offset)

View File

@ -0,0 +1,57 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import status as http_status
from ..models import MarketplaceBidRequest, MarketplaceOfferView, MarketplaceStatsView
from ..services import MarketplaceService
from ..storage import SessionDep
router = APIRouter(tags=["marketplace"])
def _get_service(session: SessionDep) -> MarketplaceService:
return MarketplaceService(session)
@router.get(
"/marketplace/offers",
response_model=list[MarketplaceOfferView],
summary="List marketplace offers",
)
async def list_marketplace_offers(
*,
session: SessionDep,
status_filter: str | None = Query(default=None, alias="status", description="Filter by offer status"),
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> list[MarketplaceOfferView]:
service = _get_service(session)
try:
return service.list_offers(status=status_filter, limit=limit, offset=offset)
except ValueError:
raise HTTPException(status_code=http_status.HTTP_400_BAD_REQUEST, detail="invalid status filter") from None
@router.get(
"/marketplace/stats",
response_model=MarketplaceStatsView,
summary="Get marketplace summary statistics",
)
async def get_marketplace_stats(*, session: SessionDep) -> MarketplaceStatsView:
service = _get_service(session)
return service.get_stats()
@router.post(
"/marketplace/bids",
status_code=http_status.HTTP_202_ACCEPTED,
summary="Submit a marketplace bid",
)
async def submit_marketplace_bid(
payload: MarketplaceBidRequest,
session: SessionDep,
) -> dict[str, str]:
service = _get_service(session)
bid = service.create_bid(payload)
return {"id": bid.id}

View File

@ -2,5 +2,7 @@
from .jobs import JobService
from .miners import MinerService
from .marketplace import MarketplaceService
from .explorer import ExplorerService
__all__ = ["JobService", "MinerService"]
__all__ = ["JobService", "MinerService", "MarketplaceService", "ExplorerService"]

View File

@ -0,0 +1,182 @@
from __future__ import annotations
from collections import defaultdict, deque
from datetime import datetime
from typing import Optional
from sqlmodel import Session, select
from ..domain import Job, JobReceipt
from ..models import (
BlockListResponse,
BlockSummary,
TransactionListResponse,
TransactionSummary,
AddressListResponse,
AddressSummary,
ReceiptListResponse,
ReceiptSummary,
JobState,
)
_STATUS_LABELS = {
JobState.queued: "Queued",
JobState.running: "Running",
JobState.completed: "Succeeded",
JobState.failed: "Failed",
JobState.canceled: "Canceled",
JobState.expired: "Expired",
}
_DEFAULT_HEIGHT_BASE = 100_000
class ExplorerService:
"""Derives explorer-friendly summaries from coordinator data."""
def __init__(self, session: Session) -> None:
self.session = session
def list_blocks(self, *, limit: int = 20, offset: int = 0) -> BlockListResponse:
statement = select(Job).order_by(Job.requested_at.desc())
jobs = self.session.exec(statement.offset(offset).limit(limit)).all()
items: list[BlockSummary] = []
for index, job in enumerate(jobs):
height = _DEFAULT_HEIGHT_BASE + offset + index
proposer = job.assigned_miner_id or "unassigned"
items.append(
BlockSummary(
height=height,
hash=job.id,
timestamp=job.requested_at,
tx_count=1,
proposer=proposer,
)
)
next_offset: Optional[int] = offset + len(items) if len(items) == limit else None
return BlockListResponse(items=items, next_offset=next_offset)
def list_transactions(self, *, limit: int = 50, offset: int = 0) -> TransactionListResponse:
statement = (
select(Job)
.order_by(Job.requested_at.desc())
.offset(offset)
.limit(limit)
)
jobs = self.session.exec(statement).all()
items: list[TransactionSummary] = []
for index, job in enumerate(jobs):
height = _DEFAULT_HEIGHT_BASE + offset + index
status_label = _STATUS_LABELS.get(job.state, job.state.value.title())
value = job.payload.get("value") if isinstance(job.payload, dict) else None
if value is None:
value_str = "0"
elif isinstance(value, (int, float)):
value_str = f"{value}"
else:
value_str = str(value)
items.append(
TransactionSummary(
hash=job.id,
block=height,
from_address=job.client_id,
to_address=job.assigned_miner_id,
value=value_str,
status=status_label,
)
)
next_offset: Optional[int] = offset + len(items) if len(items) == limit else None
return TransactionListResponse(items=items, next_offset=next_offset)
def list_addresses(self, *, limit: int = 50, offset: int = 0) -> AddressListResponse:
statement = select(Job).order_by(Job.requested_at.desc())
jobs = self.session.exec(statement.offset(offset).limit(limit)).all()
address_map: dict[str, dict[str, object]] = defaultdict(
lambda: {
"address": "",
"balance": "0",
"tx_count": 0,
"last_active": datetime.min,
"recent_transactions": deque(maxlen=5),
}
)
def touch(address: Optional[str], tx_id: str, when: datetime, value_hint: Optional[str] = None) -> None:
if not address:
return
entry = address_map[address]
entry["address"] = address
entry["tx_count"] = int(entry["tx_count"]) + 1
if when > entry["last_active"]:
entry["last_active"] = when
if value_hint:
entry["balance"] = value_hint
recent: deque[str] = entry["recent_transactions"] # type: ignore[assignment]
recent.appendleft(tx_id)
for job in jobs:
value = job.payload.get("value") if isinstance(job.payload, dict) else None
value_hint: Optional[str] = None
if value is not None:
value_hint = str(value)
touch(job.client_id, job.id, job.requested_at, value_hint=value_hint)
touch(job.assigned_miner_id, job.id, job.requested_at)
sorted_addresses = sorted(
address_map.values(),
key=lambda entry: entry["last_active"],
reverse=True,
)
sliced = sorted_addresses[offset : offset + limit]
items = [
AddressSummary(
address=entry["address"],
balance=str(entry["balance"]),
txCount=int(entry["tx_count"]),
lastActive=entry["last_active"],
recentTransactions=list(entry["recent_transactions"]),
)
for entry in sliced
]
next_offset: Optional[int] = offset + len(sliced) if len(sliced) == limit else None
return AddressListResponse(items=items, next_offset=next_offset)
def list_receipts(
self,
*,
job_id: Optional[str] = None,
limit: int = 50,
offset: int = 0,
) -> ReceiptListResponse:
statement = select(JobReceipt).order_by(JobReceipt.created_at.desc())
if job_id:
statement = statement.where(JobReceipt.job_id == job_id)
rows = self.session.exec(statement.offset(offset).limit(limit)).all()
items: list[ReceiptSummary] = []
for row in rows:
payload = row.payload or {}
miner = payload.get("miner") or payload.get("miner_id") or "unknown"
coordinator = payload.get("coordinator") or payload.get("coordinator_id") or "unknown"
status = payload.get("status") or payload.get("state") or "Unknown"
items.append(
ReceiptSummary(
receipt_id=row.receipt_id,
miner=miner,
coordinator=coordinator,
issued_at=row.created_at,
status=status,
payload=payload,
)
)
resolved_job_id = job_id or "all"
return ReceiptListResponse(job_id=resolved_job_id, items=items)

View File

@ -0,0 +1,83 @@
from __future__ import annotations
from statistics import mean
from typing import Iterable, Optional
from sqlmodel import Session, select
from ..domain import MarketplaceOffer, MarketplaceBid, OfferStatus
from ..models import (
MarketplaceBidRequest,
MarketplaceOfferView,
MarketplaceStatsView,
)
class MarketplaceService:
"""Business logic for marketplace offers, stats, and bids."""
def __init__(self, session: Session) -> None:
self.session = session
def list_offers(
self,
*,
status: Optional[str] = None,
limit: int = 100,
offset: int = 0,
) -> list[MarketplaceOfferView]:
statement = select(MarketplaceOffer).order_by(MarketplaceOffer.created_at.desc())
if status:
try:
desired_status = OfferStatus(status.lower())
except ValueError as exc: # pragma: no cover - validated in router
raise ValueError("invalid status filter") from exc
statement = statement.where(MarketplaceOffer.status == desired_status)
if offset:
statement = statement.offset(offset)
if limit:
statement = statement.limit(limit)
offers = self.session.exec(statement).all()
return [self._to_offer_view(offer) for offer in offers]
def get_stats(self) -> MarketplaceStatsView:
offers = self.session.exec(select(MarketplaceOffer)).all()
open_offers = [offer for offer in offers if offer.status == OfferStatus.open]
total_offers = len(offers)
open_capacity = sum(offer.capacity for offer in open_offers)
average_price = mean([offer.price for offer in open_offers]) if open_offers else 0.0
active_bids = self.session.exec(
select(MarketplaceBid).where(MarketplaceBid.status == "pending")
).all()
return MarketplaceStatsView(
totalOffers=total_offers,
openCapacity=open_capacity,
averagePrice=round(average_price, 4),
activeBids=len(active_bids),
)
def create_bid(self, payload: MarketplaceBidRequest) -> MarketplaceBid:
bid = MarketplaceBid(
provider=payload.provider,
capacity=payload.capacity,
price=payload.price,
notes=payload.notes,
)
self.session.add(bid)
self.session.commit()
self.session.refresh(bid)
return bid
@staticmethod
def _to_offer_view(offer: MarketplaceOffer) -> MarketplaceOfferView:
return MarketplaceOfferView(
id=offer.id,
provider=offer.provider,
capacity=offer.capacity,
price=offer.price,
sla=offer.sla,
status=offer.status.value,
created_at=offer.created_at,
)

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
from ..domain import Job, Miner, MarketplaceOffer, MarketplaceBid
_engine: Engine | None = None

View File

@ -0,0 +1,113 @@
import pytest
from fastapi.testclient import TestClient
from sqlmodel import Session, delete
from app.config import settings
from app.domain import MarketplaceOffer, OfferStatus, MarketplaceBid
from app.main import create_app
from app.services.marketplace import MarketplaceService
from app.storage.db import init_db, session_scope
@pytest.fixture(scope="module", autouse=True)
def _init_db(tmp_path_factory):
db_file = tmp_path_factory.mktemp("data") / "marketplace.db"
settings.database_url = f"sqlite:///{db_file}"
init_db()
yield
@pytest.fixture()
def session():
with session_scope() as sess:
sess.exec(delete(MarketplaceBid))
sess.exec(delete(MarketplaceOffer))
sess.commit()
yield sess
@pytest.fixture()
def client():
app = create_app()
return TestClient(app)
def test_list_offers_filters_by_status(client: TestClient, session: Session):
open_offer = MarketplaceOffer(provider="Alpha", capacity=250, price=12.5, sla="99.9%", status=OfferStatus.open)
reserved_offer = MarketplaceOffer(provider="Beta", capacity=100, price=15.0, sla="99.5%", status=OfferStatus.reserved)
session.add(open_offer)
session.add(reserved_offer)
session.commit()
# All offers
resp = client.get("/v1/marketplace/offers")
assert resp.status_code == 200
payload = resp.json()
assert len(payload) == 2
# Filter by status
resp_open = client.get("/v1/marketplace/offers", params={"status": "open"})
assert resp_open.status_code == 200
open_payload = resp_open.json()
assert len(open_payload) == 1
assert open_payload[0]["provider"] == "Alpha"
# Invalid status yields 400
resp_invalid = client.get("/v1/marketplace/offers", params={"status": "invalid"})
assert resp_invalid.status_code == 400
def test_marketplace_stats(client: TestClient, session: Session):
session.add_all(
[
MarketplaceOffer(provider="Alpha", capacity=200, price=10.0, sla="99.9%", status=OfferStatus.open),
MarketplaceOffer(provider="Beta", capacity=150, price=20.0, sla="99.5%", status=OfferStatus.open),
MarketplaceOffer(provider="Gamma", capacity=90, price=12.0, sla="99.0%", status=OfferStatus.reserved),
]
)
session.commit()
resp = client.get("/v1/marketplace/stats")
assert resp.status_code == 200
stats = resp.json()
assert stats["totalOffers"] == 3
assert stats["openCapacity"] == 350
assert pytest.approx(stats["averagePrice"], rel=1e-3) == 15.0
assert stats["activeBids"] == 0
def test_submit_bid_creates_record(client: TestClient, session: Session):
payload = {
"provider": "Alpha",
"capacity": 120,
"price": 13.5,
"notes": "Need overnight capacity",
}
resp = client.post("/v1/marketplace/bids", json=payload)
assert resp.status_code == 202
response_payload = resp.json()
assert "id" in response_payload
bid = session.get(MarketplaceBid, response_payload["id"])
assert bid is not None
assert bid.provider == payload["provider"]
assert bid.capacity == payload["capacity"]
assert bid.price == payload["price"]
assert bid.notes == payload["notes"]
def test_marketplace_service_list_offers_handles_limit_offset(session: Session):
session.add_all(
[
MarketplaceOffer(provider="A", capacity=50, price=9.0, sla="99.0%", status=OfferStatus.open),
MarketplaceOffer(provider="B", capacity=70, price=11.0, sla="99.0%", status=OfferStatus.open),
MarketplaceOffer(provider="C", capacity=90, price=13.0, sla="99.0%", status=OfferStatus.open),
]
)
session.commit()
service = MarketplaceService(session)
limited = service.list_offers(limit=2, offset=1)
assert len(limited) == 2
# Offers ordered by created_at descending → last inserted first
assert {offer.provider for offer in limited} == {"B", "A"}

View File

@ -6,7 +6,6 @@ Static web explorer for the AITBC blockchain node, displaying blocks, transactio
## Development Setup
- Install dependencies:
```bash
npm install
```
@ -14,145 +13,33 @@ Static web explorer for the AITBC blockchain node, displaying blocks, transactio
```bash
npm run dev
```
- The explorer ships with mock data in `public/mock/` that powers the tables by default.
The dev server listens on `http://localhost:5173/` by default. Adjust via `--host`/`--port` flags in the `systemd` unit or `package.json` script.
### Data Mode Toggle
## Data Mode Toggle
- Configuration lives in `src/config.ts` and can be overridden with environment variables.
- Use `VITE_DATA_MODE` to choose between `mock` (default) and `live`.
- When switching to live data, set `VITE_COORDINATOR_API` to the coordinator base URL (e.g. `http://localhost:8000`).
- When switching to live data, set `VITE_COORDINATOR_API` to the coordinator base URL (e.g., `http://localhost:8000`).
- Example `.env` snippet:
```bash
VITE_DATA_MODE=live
VITE_COORDINATOR_API=https://coordinator.dev.internal
```
With live mode enabled, the SPA will request `/v1/<resource>` routes from the coordinator instead of the bundled mock JSON.
## Next Steps
## Feature Flags & Auth
- Build out responsive styling and navigation interactions.
- Extend the data layer to support coordinator authentication and pagination when live endpoints are ready.
- Document coordinator API assumptions once the backend contracts stabilize.
- Document any backend expectations (e.g., coordinator accepting bearer tokens) alongside the environment variables in deployment manifests.
## Coordinator API Contracts (Draft)
## End-to-End Tests
- **Blocks** (`GET /v1/blocks?limit=&offset=`)
- Expected payload:
```json
{
"items": [
{
"height": 12045,
"hash": "0x...",
"timestamp": "2025-09-27T01:58:12Z",
"tx_count": 8,
"proposer": "miner-alpha"
}
],
"next_offset": 12040
}
```
- TODO: confirm pagination fields and proposer metadata.
- Install browsers after `npm install` by running `npx playwright install`.
- Launch the dev server (or point `EXPLORER_BASE_URL` at an already running instance) and run:
```bash
npm run test:e2e
```
- Tests automatically persist live mode and stub coordinator responses to verify overview, blocks, and transactions views.
- **Transactions** (`GET /v1/transactions?limit=&offset=`)
- Expected payload:
```json
{
"items": [
{
"hash": "0x...",
"block": 12045,
"from": "0x...",
"to": "0x...",
"value": "12.5",
"status": "Succeeded"
}
],
"next_offset": "0x..."
}
```
- TODO: finalize value units (AIT vs wei) and status enum.
## Playwright
- **Addresses** (`GET /v1/addresses/{address}`)
- Expected payload:
```json
{
"address": "0x...",
"balance": "1450.25",
"tx_count": 42,
"last_active": "2025-09-27T01:48:00Z",
"recent_transactions": ["0x..."]
}
```
- TODO: detail pagination for recent transactions and add receipt summary references.
- **Receipts** (`GET /v1/jobs/{job_id}/receipts`)
- Expected payload:
```json
{
"job_id": "job-0001",
"items": [
{
"receipt_id": "rcpt-123",
"miner": "miner-alpha",
"coordinator": "coordinator-001",
"issued_at": "2025-09-27T01:52:22Z",
"status": "Attested",
"payload": {
"miner_signature": "0x...",
"coordinator_signature": "0x..."
}
}
]
}
```
- TODO: confirm signature payload structure and include attestation metadata.
## Styling Guide
- **`public/css/base.css`**
- Defines global typography, color scheme, and utility classes (tables, placeholders, code tags).
- Use this file for cross-page primitives and reset/normalization rules.
- When adding new utilities (e.g., badges, alerts), document them in this section and keep naming consistent with the existing BEM-lite approach.
- **`public/css/layout.css`**
- Contains structural styles for the Explorer shell (header, footer, cards, forms, grids).
- Encapsulate component-specific classes with a predictable prefix, such as `.blocks__table`, `.addresses__input-group`, or `.receipts__controls`.
- Prefer utility classes from `base.css` when possible, and only introduce new layout classes when a component requires dedicated styling.
- **Adding New Components**
- Create semantic markup first in `src/pages/` or `src/components/`, using descriptive class names that map to the page or component (`.transactions__filter`, `.overview__chart`).
- Extend `layout.css` with matching selectors to style the new elements; keep related rules grouped together for readability.
- For reusable widgets across multiple pages, consider extracting shared styles into a dedicated section or introducing a new partial CSS file when the component becomes complex.
## Deployment Notes
- **Environment Variables**
- `VITE_DATA_MODE`: `mock` (default) or `live`.
- `VITE_COORDINATOR_API`: Base URL for coordinator API when `live` mode is enabled.
- Additional Vite variables can be added following the `VITE_*` naming convention.
- **Mock vs Live**
- In non-production environments, keep `VITE_DATA_MODE=mock` to serve the static JSON under `public/mock/` for quick demos.
- For staging/production deployments, set `VITE_DATA_MODE=live` and ensure the coordinator endpoint is reachable from the frontend origin; configure CORS accordingly on the backend.
- Consider serving mock JSON from a CDN or static bucket if you want deterministic demos while backend dependencies are under development.
- **Build & Deploy**
- Build command: `npm run build` (outputs to `dist/`).
- Preview locally with `npm run preview` before publishing.
- Deploy the `dist/` contents to your static host (e.g., Nginx, S3 + CloudFront, Vercel). Ensure environment variables are injected at build time or through runtime configuration mechanisms supported by your hosting provider.
## Error Handling (Live Mode)
- **Status Codes**
- `2xx`: Treat as success; map response bodies into the typed models in `src/lib/models.ts`.
- `4xx`: Surface actionable messages to the user (e.g., invalid job ID). For `404`, show “not found” states in the relevant page. For `429`, display a rate-limit notice and back off.
- `5xx`: Show a generic coordinator outage message and trigger retry logic.
- **Retry Strategy**
- Use an exponential backoff with jitter when retrying `5xx` or network failures (suggested base delay 500ms, max 5attempts).
- Do not retry on `4xx` except `429`; instead, display feedback.
- **Telemetry & Logging**
- Consider emitting console warnings or hooking into an analytics layer when retries occur, noting the endpoint and status code.
- Bubble critical errors via a shared notification component so users understand whether data is stale or unavailable.
- Run `npm run test:e2e` to execute the end-to-end tests.
- The tests will automatically persist live mode and stub coordinator responses to verify overview, blocks, and transactions views.

View File

@ -5,10 +5,13 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"test:e2e": "playwright test"
},
"dependencies": {},
"devDependencies": {
"@playwright/test": "^1.48.0",
"@types/node": "^20.12.7",
"typescript": "^5.4.0",
"vite": "^5.2.0"
}

View File

@ -0,0 +1,23 @@
import { defineConfig, devices } from "@playwright/test";
const PORT = process.env.EXPLORER_DEV_PORT ?? "5173";
const HOST = process.env.EXPLORER_DEV_HOST ?? "127.0.0.1";
export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
reporter: process.env.CI ? "github" : "list",
use: {
baseURL: process.env.EXPLORER_BASE_URL ?? `http://${HOST}:${PORT}`,
trace: "on-first-retry",
viewport: { width: 1280, height: 720 },
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
});

View File

@ -29,12 +29,52 @@
flex: 1 1 45%;
text-align: center;
}
.addresses__input-group,
.receipts__input-group {
flex-direction: column;
}
.overview__grid {
grid-template-columns: 1fr;
}
.table thead {
display: none;
}
.table tr {
display: grid;
gap: 0.5rem;
padding: 1rem 0;
border-bottom: 1px solid rgba(125, 196, 255, 0.12);
}
.table td {
display: flex;
justify-content: space-between;
gap: 0.75rem;
padding: 0.25rem 0;
}
}
@media (min-width: 768px) {
.overview__grid {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.table thead {
display: table-header-group;
}
.table tr {
display: table-row;
}
.table td {
display: table-cell;
}
}
@media (max-width: 768px) {
.toast-container {
left: 0;
right: 0;
@ -169,6 +209,18 @@
flex: 1 1 auto;
text-align: center;
}
.page {
padding: 1.5rem;
}
.overview__grid {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.table td {
font-size: 0.95rem;
}
}
.section-header {

View File

@ -4,6 +4,7 @@ import type {
BlockListResponse,
TransactionListResponse,
AddressDetailResponse,
AddressListResponse,
ReceiptListResponse,
BlockSummary,
TransactionSummary,
@ -11,7 +12,33 @@ import type {
ReceiptSummary,
} from "./models.ts";
let currentMode: DataMode = CONFIG.dataMode;
const STORAGE_KEY = "aitbc-explorer:data-mode";
function loadStoredMode(): DataMode | null {
if (typeof window === "undefined") {
return null;
}
try {
const value = window.localStorage.getItem(STORAGE_KEY);
if (value === "mock" || value === "live") {
return value as DataMode;
}
} catch (error) {
console.warn("[Explorer] Unable to read stored data mode", error);
}
return null;
}
const initialMode = loadStoredMode() ?? CONFIG.dataMode;
let currentMode: DataMode = initialMode;
function syncDocumentMode(mode: DataMode): void {
if (typeof document !== "undefined") {
document.documentElement.dataset.mode = mode;
}
}
syncDocumentMode(currentMode);
export function getDataMode(): DataMode {
return currentMode;
@ -19,6 +46,14 @@ export function getDataMode(): DataMode {
export function setDataMode(mode: DataMode): void {
currentMode = mode;
syncDocumentMode(mode);
if (typeof window !== "undefined") {
try {
window.localStorage.setItem(STORAGE_KEY, mode);
} catch (error) {
console.warn("[Explorer] Failed to persist data mode", error);
}
}
}
export async function fetchBlocks(): Promise<BlockSummary[]> {
@ -28,15 +63,15 @@ export async function fetchBlocks(): Promise<BlockSummary[]> {
}
try {
const response = await fetch(`${CONFIG.apiBaseUrl}/v1/blocks`);
const response = await fetch(`${CONFIG.apiBaseUrl}/v1/explorer/blocks`);
if (!response.ok) {
throw new Error(`Failed to fetch blocks: ${response.status}`);
throw new Error(`Failed to fetch blocks: ${response.status} ${response.statusText}`);
}
const data = (await response.json()) as BlockListResponse;
return data.items;
} catch (error) {
console.warn("[Explorer] Failed to fetch live block data", error);
notifyError("Unable to load live block data. Displaying placeholders.");
console.error("[Explorer] Failed to fetch live block data", error);
notifyError("Unable to load live block data from coordinator. Showing placeholders.");
return [];
}
}
@ -48,15 +83,15 @@ export async function fetchTransactions(): Promise<TransactionSummary[]> {
}
try {
const response = await fetch(`${CONFIG.apiBaseUrl}/v1/transactions`);
const response = await fetch(`${CONFIG.apiBaseUrl}/v1/explorer/transactions`);
if (!response.ok) {
throw new Error(`Failed to fetch transactions: ${response.status}`);
throw new Error(`Failed to fetch transactions: ${response.status} ${response.statusText}`);
}
const data = (await response.json()) as TransactionListResponse;
return data.items;
} catch (error) {
console.warn("[Explorer] Failed to fetch live transaction data", error);
notifyError("Unable to load live transaction data. Displaying placeholders.");
console.error("[Explorer] Failed to fetch live transaction data", error);
notifyError("Unable to load transactions from coordinator. Showing placeholders.");
return [];
}
}
@ -68,15 +103,15 @@ export async function fetchAddresses(): Promise<AddressSummary[]> {
}
try {
const response = await fetch(`${CONFIG.apiBaseUrl}/v1/addresses`);
const response = await fetch(`${CONFIG.apiBaseUrl}/v1/explorer/addresses`);
if (!response.ok) {
throw new Error(`Failed to fetch addresses: ${response.status}`);
throw new Error(`Failed to fetch addresses: ${response.status} ${response.statusText}`);
}
const data = (await response.json()) as { items: AddressDetailResponse[] } | AddressDetailResponse[];
return Array.isArray(data) ? data : data.items;
const data = (await response.json()) as AddressListResponse;
return data.items;
} catch (error) {
console.warn("[Explorer] Failed to fetch live address data", error);
notifyError("Unable to load live address data. Displaying placeholders.");
console.error("[Explorer] Failed to fetch live address data", error);
notifyError("Unable to load address summaries from coordinator. Showing placeholders.");
return [];
}
}
@ -88,15 +123,15 @@ export async function fetchReceipts(): Promise<ReceiptSummary[]> {
}
try {
const response = await fetch(`${CONFIG.apiBaseUrl}/v1/receipts`);
const response = await fetch(`${CONFIG.apiBaseUrl}/v1/explorer/receipts`);
if (!response.ok) {
throw new Error(`Failed to fetch receipts: ${response.status}`);
throw new Error(`Failed to fetch receipts: ${response.status} ${response.statusText}`);
}
const data = (await response.json()) as ReceiptListResponse;
return data.items;
} catch (error) {
console.warn("[Explorer] Failed to fetch live receipt data", error);
notifyError("Unable to load live receipt data. Displaying placeholders.");
console.error("[Explorer] Failed to fetch live receipt data", error);
notifyError("Unable to load receipts from coordinator. Showing placeholders.");
return [];
}
}

View File

@ -0,0 +1,8 @@
{
"status": "failed",
"failedTests": [
"78c83d26793c923d7fe5-1c82705bd81364a8b68d",
"78c83d26793c923d7fe5-d8983ad99256a494df4f",
"78c83d26793c923d7fe5-a5eb02c7b1bcc34f643e"
]
}

View File

@ -0,0 +1,91 @@
# Page snapshot
```yaml
- generic [ref=e2]:
- main [ref=e3]:
- generic [ref=e4]:
- paragraph [ref=e5]:
- text: "Data mode:"
- strong [ref=e6]: MOCK
- heading "Marketplace Control Center" [level=1] [ref=e7]
- paragraph [ref=e8]: Monitor available offers, submit bids, and review marketplace health at a glance.
- generic [ref=e9]:
- article [ref=e10]:
- heading "Total Offers" [level=2] [ref=e11]
- strong [ref=e12]: "78"
- generic [ref=e13]: Listings currently visible
- article [ref=e14]:
- heading "Open Capacity" [level=2] [ref=e15]
- strong [ref=e16]: 1,120 units
- generic [ref=e17]: GPU / compute units available
- article [ref=e18]:
- heading "Average Price" [level=2] [ref=e19]
- strong [ref=e20]: 14.30 credits
- generic [ref=e21]: Credits per unit per hour
- article [ref=e22]:
- heading "Active Bids" [level=2] [ref=e23]
- strong [ref=e24]: "36"
- generic [ref=e25]: Open bids awaiting match
- generic [ref=e26]:
- article [ref=e27]:
- heading "Available Offers" [level=2] [ref=e28]
- table [ref=e31]:
- rowgroup [ref=e32]:
- row "ID Provider Capacity Price SLA Status" [ref=e33]:
- cell "ID" [ref=e34]
- cell "Provider" [ref=e35]
- cell "Capacity" [ref=e36]
- cell "Price" [ref=e37]
- cell "SLA" [ref=e38]
- cell "Status" [ref=e39]
- rowgroup [ref=e40]:
- row "offer-101 Alpha Pool 250 units 12.50 99.9% Open" [ref=e41]:
- cell "offer-101" [ref=e42]
- cell "Alpha Pool" [ref=e43]
- cell "250 units" [ref=e44]
- cell "12.50" [ref=e45]
- cell "99.9%" [ref=e46]
- cell "Open" [ref=e47]:
- generic [ref=e48]: Open
- row "offer-102 Beta Collective 140 units 15.75 99.5% Open" [ref=e49]:
- cell "offer-102" [ref=e50]
- cell "Beta Collective" [ref=e51]
- cell "140 units" [ref=e52]
- cell "15.75" [ref=e53]
- cell "99.5%" [ref=e54]
- cell "Open" [ref=e55]:
- generic [ref=e56]: Open
- row "offer-103 Gamma Compute 400 units 10.90 99.95% Reserved" [ref=e57]:
- cell "offer-103" [ref=e58]
- cell "Gamma Compute" [ref=e59]
- cell "400 units" [ref=e60]
- cell "10.90" [ref=e61]
- cell "99.95%" [ref=e62]
- cell "Reserved" [ref=e63]:
- generic [ref=e64]: Reserved
- row "offer-104 Delta Grid 90 units 18.25 99.0% Open" [ref=e65]:
- cell "offer-104" [ref=e66]
- cell "Delta Grid" [ref=e67]
- cell "90 units" [ref=e68]
- cell "18.25" [ref=e69]
- cell "99.0%" [ref=e70]
- cell "Open" [ref=e71]:
- generic [ref=e72]: Open
- article [ref=e73]:
- heading "Submit a Bid" [level=2] [ref=e74]
- generic [ref=e75]:
- generic [ref=e76]:
- generic [ref=e77]: Preferred provider
- textbox "Preferred provider" [ref=e78]
- generic [ref=e79]:
- generic [ref=e80]: Capacity required (units)
- spinbutton "Capacity required (units)" [ref=e81]
- generic [ref=e82]:
- generic [ref=e83]: Bid price (credits/unit/hr)
- spinbutton "Bid price (credits/unit/hr)" [ref=e84]
- generic [ref=e85]:
- generic [ref=e86]: Notes (optional)
- textbox "Notes (optional)" [ref=e87]
- button "Submit Bid" [ref=e88] [cursor=pointer]
- complementary [ref=e89]
```

View File

@ -0,0 +1,91 @@
# Page snapshot
```yaml
- generic [ref=e2]:
- main [ref=e3]:
- generic [ref=e4]:
- paragraph [ref=e5]:
- text: "Data mode:"
- strong [ref=e6]: MOCK
- heading "Marketplace Control Center" [level=1] [ref=e7]
- paragraph [ref=e8]: Monitor available offers, submit bids, and review marketplace health at a glance.
- generic [ref=e9]:
- article [ref=e10]:
- heading "Total Offers" [level=2] [ref=e11]
- strong [ref=e12]: "78"
- generic [ref=e13]: Listings currently visible
- article [ref=e14]:
- heading "Open Capacity" [level=2] [ref=e15]
- strong [ref=e16]: 1,120 units
- generic [ref=e17]: GPU / compute units available
- article [ref=e18]:
- heading "Average Price" [level=2] [ref=e19]
- strong [ref=e20]: 14.30 credits
- generic [ref=e21]: Credits per unit per hour
- article [ref=e22]:
- heading "Active Bids" [level=2] [ref=e23]
- strong [ref=e24]: "36"
- generic [ref=e25]: Open bids awaiting match
- generic [ref=e26]:
- article [ref=e27]:
- heading "Available Offers" [level=2] [ref=e28]
- table [ref=e31]:
- rowgroup [ref=e32]:
- row "ID Provider Capacity Price SLA Status" [ref=e33]:
- cell "ID" [ref=e34]
- cell "Provider" [ref=e35]
- cell "Capacity" [ref=e36]
- cell "Price" [ref=e37]
- cell "SLA" [ref=e38]
- cell "Status" [ref=e39]
- rowgroup [ref=e40]:
- row "offer-101 Alpha Pool 250 units 12.50 99.9% Open" [ref=e41]:
- cell "offer-101" [ref=e42]
- cell "Alpha Pool" [ref=e43]
- cell "250 units" [ref=e44]
- cell "12.50" [ref=e45]
- cell "99.9%" [ref=e46]
- cell "Open" [ref=e47]:
- generic [ref=e48]: Open
- row "offer-102 Beta Collective 140 units 15.75 99.5% Open" [ref=e49]:
- cell "offer-102" [ref=e50]
- cell "Beta Collective" [ref=e51]
- cell "140 units" [ref=e52]
- cell "15.75" [ref=e53]
- cell "99.5%" [ref=e54]
- cell "Open" [ref=e55]:
- generic [ref=e56]: Open
- row "offer-103 Gamma Compute 400 units 10.90 99.95% Reserved" [ref=e57]:
- cell "offer-103" [ref=e58]
- cell "Gamma Compute" [ref=e59]
- cell "400 units" [ref=e60]
- cell "10.90" [ref=e61]
- cell "99.95%" [ref=e62]
- cell "Reserved" [ref=e63]:
- generic [ref=e64]: Reserved
- row "offer-104 Delta Grid 90 units 18.25 99.0% Open" [ref=e65]:
- cell "offer-104" [ref=e66]
- cell "Delta Grid" [ref=e67]
- cell "90 units" [ref=e68]
- cell "18.25" [ref=e69]
- cell "99.0%" [ref=e70]
- cell "Open" [ref=e71]:
- generic [ref=e72]: Open
- article [ref=e73]:
- heading "Submit a Bid" [level=2] [ref=e74]
- generic [ref=e75]:
- generic [ref=e76]:
- generic [ref=e77]: Preferred provider
- textbox "Preferred provider" [ref=e78]
- generic [ref=e79]:
- generic [ref=e80]: Capacity required (units)
- spinbutton "Capacity required (units)" [ref=e81]
- generic [ref=e82]:
- generic [ref=e83]: Bid price (credits/unit/hr)
- spinbutton "Bid price (credits/unit/hr)" [ref=e84]
- generic [ref=e85]:
- generic [ref=e86]: Notes (optional)
- textbox "Notes (optional)" [ref=e87]
- button "Submit Bid" [ref=e88] [cursor=pointer]
- complementary [ref=e89]
```

View File

@ -0,0 +1,91 @@
# Page snapshot
```yaml
- generic [ref=e2]:
- main [ref=e3]:
- generic [ref=e4]:
- paragraph [ref=e5]:
- text: "Data mode:"
- strong [ref=e6]: MOCK
- heading "Marketplace Control Center" [level=1] [ref=e7]
- paragraph [ref=e8]: Monitor available offers, submit bids, and review marketplace health at a glance.
- generic [ref=e9]:
- article [ref=e10]:
- heading "Total Offers" [level=2] [ref=e11]
- strong [ref=e12]: "78"
- generic [ref=e13]: Listings currently visible
- article [ref=e14]:
- heading "Open Capacity" [level=2] [ref=e15]
- strong [ref=e16]: 1,120 units
- generic [ref=e17]: GPU / compute units available
- article [ref=e18]:
- heading "Average Price" [level=2] [ref=e19]
- strong [ref=e20]: 14.30 credits
- generic [ref=e21]: Credits per unit per hour
- article [ref=e22]:
- heading "Active Bids" [level=2] [ref=e23]
- strong [ref=e24]: "36"
- generic [ref=e25]: Open bids awaiting match
- generic [ref=e26]:
- article [ref=e27]:
- heading "Available Offers" [level=2] [ref=e28]
- table [ref=e31]:
- rowgroup [ref=e32]:
- row "ID Provider Capacity Price SLA Status" [ref=e33]:
- cell "ID" [ref=e34]
- cell "Provider" [ref=e35]
- cell "Capacity" [ref=e36]
- cell "Price" [ref=e37]
- cell "SLA" [ref=e38]
- cell "Status" [ref=e39]
- rowgroup [ref=e40]:
- row "offer-101 Alpha Pool 250 units 12.50 99.9% Open" [ref=e41]:
- cell "offer-101" [ref=e42]
- cell "Alpha Pool" [ref=e43]
- cell "250 units" [ref=e44]
- cell "12.50" [ref=e45]
- cell "99.9%" [ref=e46]
- cell "Open" [ref=e47]:
- generic [ref=e48]: Open
- row "offer-102 Beta Collective 140 units 15.75 99.5% Open" [ref=e49]:
- cell "offer-102" [ref=e50]
- cell "Beta Collective" [ref=e51]
- cell "140 units" [ref=e52]
- cell "15.75" [ref=e53]
- cell "99.5%" [ref=e54]
- cell "Open" [ref=e55]:
- generic [ref=e56]: Open
- row "offer-103 Gamma Compute 400 units 10.90 99.95% Reserved" [ref=e57]:
- cell "offer-103" [ref=e58]
- cell "Gamma Compute" [ref=e59]
- cell "400 units" [ref=e60]
- cell "10.90" [ref=e61]
- cell "99.95%" [ref=e62]
- cell "Reserved" [ref=e63]:
- generic [ref=e64]: Reserved
- row "offer-104 Delta Grid 90 units 18.25 99.0% Open" [ref=e65]:
- cell "offer-104" [ref=e66]
- cell "Delta Grid" [ref=e67]
- cell "90 units" [ref=e68]
- cell "18.25" [ref=e69]
- cell "99.0%" [ref=e70]
- cell "Open" [ref=e71]:
- generic [ref=e72]: Open
- article [ref=e73]:
- heading "Submit a Bid" [level=2] [ref=e74]
- generic [ref=e75]:
- generic [ref=e76]:
- generic [ref=e77]: Preferred provider
- textbox "Preferred provider" [ref=e78]
- generic [ref=e79]:
- generic [ref=e80]: Capacity required (units)
- spinbutton "Capacity required (units)" [ref=e81]
- generic [ref=e82]:
- generic [ref=e83]: Bid price (credits/unit/hr)
- spinbutton "Bid price (credits/unit/hr)" [ref=e84]
- generic [ref=e85]:
- generic [ref=e86]: Notes (optional)
- textbox "Notes (optional)" [ref=e87]
- button "Submit Bid" [ref=e88] [cursor=pointer]
- complementary [ref=e89]
```

View File

@ -0,0 +1,111 @@
import { test, expect } from "@playwright/test";
test.describe("Explorer live mode", () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem("aitbc-explorer:data-mode", "live");
});
await page.route("**/v1/explorer/blocks", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: [
{
height: 12345,
hash: "0xabcdef1234567890",
timestamp: new Date("2024-08-22T12:00:00Z").toISOString(),
txCount: 12,
proposer: "validator-1",
},
],
next_offset: null,
}),
});
});
await page.route("**/v1/explorer/transactions", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: [
{
hash: "0xfeed1234",
block: 12345,
from: "0xAAA",
to: "0xBBB",
value: "0.50",
status: "Succeeded",
},
],
next_offset: null,
}),
});
});
await page.route("**/v1/explorer/receipts", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
jobId: "job-1",
items: [
{
receiptId: "receipt-1",
miner: "miner-1",
coordinator: "coordinator-1",
issuedAt: new Date("2024-08-22T12:00:00Z").toISOString(),
status: "Attested",
},
],
}),
});
});
await page.route("**/v1/explorer/addresses", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: [
{
address: "0xADDRESS",
balance: "100.0",
txCount: 42,
lastActive: new Date("2024-08-22T10:00:00Z").toISOString(),
},
],
next_offset: null,
}),
});
});
});
test("overview renders live summaries", async ({ page }) => {
await page.goto("/");
await expect(page.locator("#overview-block-stats")).toContainText("12345");
await expect(page.locator("#overview-transaction-stats")).toContainText("Total Mock Tx: 1");
await expect(page.locator("#overview-receipt-stats")).toContainText("Total Receipts: 1");
});
test("blocks table shows live rows", async ({ page }) => {
await page.goto("/blocks");
const rows = page.locator("#blocks-table-body tr");
await expect(rows).toHaveCount(1);
await expect(rows.first()).toContainText("12345");
await expect(rows.first()).toContainText("validator-1");
});
test("transactions table shows live rows", async ({ page }) => {
await page.goto("/transactions");
const rows = page.locator("tbody tr");
await expect(rows).toHaveCount(1);
await expect(rows.first()).toContainText("0xfeed1234");
await expect(rows.first()).toContainText("Succeeded");
});
});

24
apps/marketplace-web/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@ -1,15 +1,41 @@
# Marketplace Web
## Purpose & Scope
Mock UI for exploring marketplace offers and submitting bids.
Vite-powered vanilla TypeScript app for listing compute offers, placing bids, and showing market analytics. Follow the implementation blueprint in `docs/bootstrap/marketplace_web.md`.
## Development
## Development Setup
```bash
npm install
npm run dev
```
- Install dependencies with `npm install` once `package.json` is defined.
- Run the dev server via `npm run dev`.
- Build for production with `npm run build` and preview using `npm run preview`.
The dev server listens on `http://localhost:5173/` by default. Adjust via `--host`/`--port` flags in the `systemd` unit or `package.json` script.
## Notes
## Data Modes
Works against mock API responses initially; switch to real coordinator/pool-hub endpoints later via `VITE_API_BASE`.
Marketplace web reuses the explorer pattern of mock vs. live data:
- Set `VITE_MARKETPLACE_DATA_MODE=mock` (default) to consume JSON fixtures under `public/mock/`.
- Set `VITE_MARKETPLACE_DATA_MODE=live` and point `VITE_MARKETPLACE_API` to the coordinator backend when integration-ready.
### Feature Flags & Auth
- `VITE_MARKETPLACE_ENABLE_BIDS` (default `true`) gates whether the bid form submits to the backend. Set to `false` to keep the UI read-only during phased rollouts.
- `VITE_MARKETPLACE_REQUIRE_AUTH` (default `false`) enforces a bearer token session before live bid submissions. Tokens are stored in `localStorage` by `src/lib/auth.ts`; the API helpers automatically attach the `Authorization` header when a session is present.
- Session JSON is expected to include `token` (string) and `expiresAt` (epoch ms). Expired or malformed entries are cleared automatically.
Document any backend expectations (e.g., coordinator accepting bearer tokens) alongside the environment variables in deployment manifests.
## Structure
- `public/mock/offers.json` sample marketplace offers.
- `public/mock/stats.json` summary dashboard statistics.
- `src/lib/api.ts` data-mode-aware fetch helpers.
- `src/main.ts` renders dashboard, offers table, and bid form.
- `src/style.css` layout and visual styling.
## Submitting Bids
When in mock mode, bid submissions simulate latency and always succeed.
When in live mode, ensure the coordinator exposes `/v1/marketplace/offers`, `/v1/marketplace/stats`, and `/v1/marketplace/bids` endpoints compatible with the JSON shapes defined in `src/lib/api.ts`.

View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>marketplace-web</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@ -0,0 +1,15 @@
{
"name": "marketplace-web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"devDependencies": {
"typescript": "~5.8.3",
"vite": "^7.1.7"
}
}

View File

@ -0,0 +1,36 @@
{
"offers": [
{
"id": "offer-101",
"provider": "Alpha Pool",
"capacity": 250,
"price": 12.5,
"sla": "99.9%",
"status": "Open"
},
{
"id": "offer-102",
"provider": "Beta Collective",
"capacity": 140,
"price": 15.75,
"sla": "99.5%",
"status": "Open"
},
{
"id": "offer-103",
"provider": "Gamma Compute",
"capacity": 400,
"price": 10.9,
"sla": "99.95%",
"status": "Reserved"
},
{
"id": "offer-104",
"provider": "Delta Grid",
"capacity": 90,
"price": 18.25,
"sla": "99.0%",
"status": "Open"
}
]
}

View File

@ -0,0 +1,6 @@
{
"totalOffers": 78,
"openCapacity": 1120,
"averagePrice": 14.3,
"activeBids": 36
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,9 @@
export function setupCounter(element: HTMLButtonElement) {
let counter = 0
const setCounter = (count: number) => {
counter = count
element.innerHTML = `count is ${counter}`
}
element.addEventListener('click', () => setCounter(counter + 1))
setCounter(0)
}

View File

@ -0,0 +1,118 @@
import { loadSession } from "./auth";
export type DataMode = "mock" | "live";
interface OfferRecord {
id: string;
provider: string;
capacity: number;
price: number;
sla: string;
status: string;
}
interface OffersResponse {
offers: OfferRecord[];
}
export interface MarketplaceStats {
totalOffers: number;
openCapacity: number;
averagePrice: number;
activeBids: number;
}
export interface MarketplaceOffer extends OfferRecord {}
const CONFIG = {
dataMode: (import.meta.env?.VITE_MARKETPLACE_DATA_MODE as DataMode) ?? "mock",
mockBase: "/mock",
apiBase: import.meta.env?.VITE_MARKETPLACE_API ?? "http://localhost:8081",
enableBids:
(import.meta.env?.VITE_MARKETPLACE_ENABLE_BIDS ?? "true").toLowerCase() !==
"false",
requireAuth:
(import.meta.env?.VITE_MARKETPLACE_REQUIRE_AUTH ?? "false").toLowerCase() ===
"true",
};
function buildHeaders(): HeadersInit {
const headers: Record<string, string> = {
"Cache-Control": "no-cache",
};
const session = loadSession();
if (session) {
headers.Authorization = `Bearer ${session.token}`;
}
return headers;
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(path, {
...init,
headers: {
...buildHeaders(),
...init?.headers,
},
});
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
return response.json() as Promise<T>;
}
export async function fetchMarketplaceStats(): Promise<MarketplaceStats> {
if (CONFIG.dataMode === "mock") {
return request<MarketplaceStats>(`${CONFIG.mockBase}/stats.json`);
}
return request<MarketplaceStats>(`${CONFIG.apiBase}/v1/marketplace/stats`);
}
export async function fetchMarketplaceOffers(): Promise<MarketplaceOffer[]> {
if (CONFIG.dataMode === "mock") {
const payload = await request<OffersResponse>(`${CONFIG.mockBase}/offers.json`);
return payload.offers;
}
return request<MarketplaceOffer[]>(`${CONFIG.apiBase}/v1/marketplace/offers`);
}
export async function submitMarketplaceBid(input: {
provider: string;
capacity: number;
price: number;
notes?: string;
}): Promise<void> {
if (!CONFIG.enableBids) {
throw new Error("Bid submissions are disabled by configuration");
}
if (CONFIG.dataMode === "mock") {
await new Promise((resolve) => setTimeout(resolve, 600));
return;
}
if (CONFIG.requireAuth && !loadSession()) {
throw new Error("Authentication required to submit bids");
}
const response = await fetch(`${CONFIG.apiBase}/v1/marketplace/bids`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...buildHeaders(),
},
body: JSON.stringify(input),
});
if (!response.ok) {
const message = await response.text();
throw new Error(message || "Failed to submit bid");
}
}
export const MARKETPLACE_CONFIG = CONFIG;

View File

@ -0,0 +1,33 @@
export interface MarketplaceSession {
token: string;
expiresAt: number;
}
const STORAGE_KEY = "marketplace-session";
export function saveSession(session: MarketplaceSession): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
}
export function loadSession(): MarketplaceSession | null {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) {
return null;
}
try {
const data = JSON.parse(raw) as MarketplaceSession;
if (typeof data.token === "string" && typeof data.expiresAt === "number") {
if (data.expiresAt > Date.now()) {
return data;
}
clearSession();
}
} catch (error) {
console.warn("Failed to parse stored marketplace session", error);
}
return null;
}
export function clearSession(): void {
localStorage.removeItem(STORAGE_KEY);
}

View File

@ -0,0 +1,216 @@
import './style.css';
import {
fetchMarketplaceOffers,
fetchMarketplaceStats,
submitMarketplaceBid,
MARKETPLACE_CONFIG,
} from './lib/api';
import type { MarketplaceOffer, MarketplaceStats } from './lib/api';
const app = document.querySelector<HTMLDivElement>('#app');
if (!app) {
throw new Error('Unable to mount marketplace app');
}
app.innerHTML = `
<main>
<header class="page-header">
<p>Data mode: <strong>${MARKETPLACE_CONFIG.dataMode.toUpperCase()}</strong></p>
<h1>Marketplace Control Center</h1>
<p>Monitor available offers, submit bids, and review marketplace health at a glance.</p>
</header>
<section class="dashboard-grid" id="stats-panel">
<article class="stat-card">
<h2>Total Offers</h2>
<strong id="stat-total-offers">--</strong>
<span>Listings currently visible</span>
</article>
<article class="stat-card">
<h2>Open Capacity</h2>
<strong id="stat-open-capacity">--</strong>
<span>GPU / compute units available</span>
</article>
<article class="stat-card">
<h2>Average Price</h2>
<strong id="stat-average-price">--</strong>
<span>Credits per unit per hour</span>
</article>
<article class="stat-card">
<h2>Active Bids</h2>
<strong id="stat-active-bids">--</strong>
<span>Open bids awaiting match</span>
</article>
</section>
<section class="panels">
<article class="panel" id="offers-panel">
<h2>Available Offers</h2>
<div id="offers-table-wrapper" class="table-wrapper">
<p class="empty-state">Fetching marketplace offers…</p>
</div>
</article>
<article class="panel">
<h2>Submit a Bid</h2>
<form class="bid-form" id="bid-form">
<div>
<label for="bid-provider">Preferred provider</label>
<input id="bid-provider" name="provider" placeholder="Alpha Pool" required />
</div>
<div>
<label for="bid-capacity">Capacity required (units)</label>
<input id="bid-capacity" name="capacity" type="number" min="1" step="1" required />
</div>
<div>
<label for="bid-price">Bid price (credits/unit/hr)</label>
<input id="bid-price" name="price" type="number" min="0" step="0.01" required />
</div>
<div>
<label for="bid-notes">Notes (optional)</label>
<textarea id="bid-notes" name="notes" rows="3" placeholder="Add constraints, time windows, etc."></textarea>
</div>
<button type="submit">Submit Bid</button>
</form>
</article>
</section>
</main>
<aside id="toast" class="toast"></aside>
`;
const selectors = {
totalOffers: document.querySelector<HTMLSpanElement>('#stat-total-offers'),
openCapacity: document.querySelector<HTMLSpanElement>('#stat-open-capacity'),
averagePrice: document.querySelector<HTMLSpanElement>('#stat-average-price'),
activeBids: document.querySelector<HTMLSpanElement>('#stat-active-bids'),
offersWrapper: document.querySelector<HTMLDivElement>('#offers-table-wrapper'),
bidForm: document.querySelector<HTMLFormElement>('#bid-form'),
toast: document.querySelector<HTMLDivElement>('#toast'),
};
function formatNumber(value: number, options: Intl.NumberFormatOptions = {}): string {
return new Intl.NumberFormat(undefined, options).format(value);
}
function renderStats(stats: MarketplaceStats): void {
selectors.totalOffers!.textContent = formatNumber(stats.totalOffers);
selectors.openCapacity!.textContent = `${formatNumber(stats.openCapacity)} units`;
selectors.averagePrice!.textContent = `${formatNumber(stats.averagePrice, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})} credits`;
selectors.activeBids!.textContent = formatNumber(stats.activeBids);
}
function statusClass(status: string): string {
switch (status.toLowerCase()) {
case 'open':
return 'status-pill status-open';
case 'reserved':
return 'status-pill status-reserved';
default:
return 'status-pill';
}
}
function renderOffers(offers: MarketplaceOffer[]): void {
if (!selectors.offersWrapper) {
return;
}
if (offers.length === 0) {
selectors.offersWrapper.innerHTML = '<p class="empty-state">No offers available right now. Check back soon or submit a bid.</p>';
return;
}
const rows = offers
.map(
(offer) => `
<tr>
<td>${offer.id}</td>
<td>${offer.provider}</td>
<td>${formatNumber(offer.capacity)} units</td>
<td>${formatNumber(offer.price, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
<td>${offer.sla}</td>
<td><span class="${statusClass(offer.status)}">${offer.status}</span></td>
</tr>
`,
)
.join('');
selectors.offersWrapper.innerHTML = `
<div class="table-responsive">
<table class="offers-table">
<thead>
<tr>
<th>ID</th>
<th>Provider</th>
<th>Capacity</th>
<th>Price</th>
<th>SLA</th>
<th>Status</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>
`;
}
function showToast(message: string, duration = 2500): void {
if (!selectors.toast) return;
selectors.toast.textContent = message;
selectors.toast.classList.add('visible');
window.setTimeout(() => {
selectors.toast?.classList.remove('visible');
}, duration);
}
async function loadDashboard(): Promise<void> {
try {
const [stats, offers] = await Promise.all([
fetchMarketplaceStats(),
fetchMarketplaceOffers(),
]);
renderStats(stats);
renderOffers(offers);
} catch (error) {
console.error(error);
if (selectors.offersWrapper) {
selectors.offersWrapper.innerHTML = '<p class="empty-state">Failed to load offers. Please retry shortly.</p>';
}
showToast('Failed to load marketplace data.');
}
}
selectors.bidForm?.addEventListener('submit', async (event) => {
event.preventDefault();
const formData = new FormData(selectors.bidForm!);
const provider = formData.get('provider')?.toString().trim();
const capacity = Number(formData.get('capacity'));
const price = Number(formData.get('price'));
const notes = formData.get('notes')?.toString().trim();
if (!provider || Number.isNaN(capacity) || Number.isNaN(price)) {
showToast('Please complete the required fields.');
return;
}
try {
selectors.bidForm!.querySelector('button')!.setAttribute('disabled', 'disabled');
await submitMarketplaceBid({ provider, capacity, price, notes });
selectors.bidForm!.reset();
showToast('Bid submitted successfully!');
} catch (error) {
console.error(error);
showToast('Unable to submit bid. Please try again.');
} finally {
selectors.bidForm!.querySelector('button')!.removeAttribute('disabled');
}
});
loadDashboard();

View File

@ -0,0 +1,219 @@
:root {
font-family: "Inter", system-ui, -apple-system, sans-serif;
color: #121212;
background-color: #f7f8fa;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
min-height: 100vh;
background: linear-gradient(180deg, #f7f8fa 0%, #eef1f6 100%);
}
#app {
max-width: 1100px;
margin: 0 auto;
padding: 48px 24px 64px;
}
.page-header {
margin-bottom: 32px;
}
.page-header h1 {
font-size: 2.4rem;
margin: 0 0 0.5rem;
color: #1d2736;
}
.page-header p {
margin: 0;
color: #5a6575;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 20px;
margin-bottom: 32px;
}
.stat-card {
background: #ffffff;
border-radius: 16px;
padding: 20px;
box-shadow: 0 12px 24px rgba(18, 24, 32, 0.08);
}
.stat-card h2 {
margin: 0 0 12px;
font-size: 1rem;
color: #64748b;
}
.stat-card strong {
font-size: 1.8rem;
color: #1d2736;
}
.stat-card span {
display: block;
margin-top: 6px;
color: #8895a7;
font-size: 0.9rem;
}
.panels {
display: grid;
gap: 24px;
}
.panel {
background: #ffffff;
border-radius: 16px;
padding: 24px;
box-shadow: 0 10px 20px rgba(15, 23, 42, 0.08);
}
.panel h2 {
margin: 0 0 16px;
font-size: 1.4rem;
color: #1d2736;
}
.offers-table {
width: 100%;
border-collapse: collapse;
}
.offers-table th,
.offers-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid #e5e9f1;
}
.offers-table th {
color: #64748b;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.offers-table tbody tr:hover {
background-color: rgba(99, 102, 241, 0.08);
}
.table-responsive {
overflow-x: auto;
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 600;
}
.status-open {
background-color: rgba(34, 197, 94, 0.12);
color: #15803d;
}
.status-reserved {
background-color: rgba(59, 130, 246, 0.12);
color: #1d4ed8;
}
.bid-form {
display: grid;
gap: 16px;
}
.bid-form label {
font-weight: 600;
color: #374151;
display: block;
margin-bottom: 6px;
}
.bid-form input,
.bid-form select,
.bid-form textarea {
width: 100%;
border-radius: 10px;
border: 1px solid #d1d9e6;
padding: 10px 12px;
font-size: 1rem;
font-family: inherit;
background-color: #f9fbff;
}
.bid-form button {
justify-self: flex-start;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
color: #ffffff;
border: none;
border-radius: 999px;
padding: 10px 20px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: transform 150ms ease, box-shadow 150ms ease;
}
.bid-form button:hover {
transform: translateY(-1px);
box-shadow: 0 10px 18px rgba(99, 102, 241, 0.3);
}
.empty-state {
padding: 24px;
text-align: center;
color: #6b7280;
border: 1px dashed #cbd5f5;
border-radius: 12px;
background-color: rgba(99, 102, 241, 0.05);
}
.toast {
position: fixed;
bottom: 24px;
right: 24px;
padding: 14px 18px;
background: #111827;
color: #ffffff;
border-radius: 12px;
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.3);
opacity: 0;
transform: translateY(12px);
transition: opacity 200ms ease, transform 200ms ease;
}
.toast.visible {
opacity: 1;
transform: translateY(0);
}
@media (max-width: 720px) {
#app {
padding: 32px 16px 48px;
}
.dashboard-grid {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.offers-table th,
.offers-table td {
padding: 10px 12px;
font-size: 0.95rem;
}
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="32" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path fill="#007ACC" d="M0 128v128h256V0H0z"></path><path fill="#FFF" d="m56.612 128.85l-.081 10.483h33.32v94.68h23.568v-94.68h33.321v-10.28c0-5.69-.122-10.444-.284-10.566c-.122-.162-20.4-.244-44.983-.203l-44.74.122l-.121 10.443Zm149.955-10.742c6.501 1.625 11.459 4.51 16.01 9.224c2.357 2.52 5.851 7.111 6.136 8.208c.08.325-11.053 7.802-17.798 11.988c-.244.162-1.22-.894-2.317-2.52c-3.291-4.795-6.745-6.867-12.028-7.233c-7.76-.528-12.759 3.535-12.718 10.321c0 1.992.284 3.17 1.097 4.795c1.707 3.536 4.876 5.649 14.832 9.956c18.326 7.883 26.168 13.084 31.045 20.48c5.445 8.249 6.664 21.415 2.966 31.208c-4.063 10.646-14.14 17.879-28.323 20.276c-4.388.772-14.79.65-19.504-.203c-10.28-1.828-20.033-6.908-26.047-13.572c-2.357-2.6-6.949-9.387-6.664-9.874c.122-.163 1.178-.813 2.356-1.504c1.138-.65 5.446-3.129 9.509-5.485l7.355-4.267l1.544 2.276c2.154 3.29 6.867 7.801 9.712 9.305c8.167 4.307 19.383 3.698 24.909-1.26c2.357-2.153 3.332-4.388 3.332-7.68c0-2.966-.366-4.266-1.91-6.501c-1.99-2.845-6.054-5.242-17.595-10.24c-13.206-5.69-18.895-9.224-24.096-14.832c-3.007-3.25-5.852-8.452-7.03-12.8c-.975-3.617-1.22-12.678-.447-16.335c2.723-12.76 12.353-21.659 26.25-24.3c4.51-.853 14.994-.528 19.424.569Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@ -96,6 +96,7 @@ def list_wallets(
WalletDescriptor(wallet_id=record.wallet_id, public_key=record.public_key, metadata=metadata)
)
return WalletListResponse(items=descriptors)
@router.post("/wallets", response_model=WalletCreateResponse, status_code=status.HTTP_201_CREATED, summary="Create wallet")
def create_wallet(
@ -119,7 +120,10 @@ def create_wallet(
metadata=request.metadata,
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"reason": "password_too_weak", "min_length": 10, "message": str(exc)},
) from exc
ledger.upsert_wallet(record.wallet_id, record.public_key, record.metadata)
ledger.record_event(record.wallet_id, "created", {"metadata": record.metadata})

View File

@ -1,13 +1,15 @@
from __future__ import annotations
import base64
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from aitbc_chain.app import create_app # noqa: I100
from app.deps import get_keystore, get_ledger
from app.deps import get_keystore, get_ledger, get_settings
from app.main import create_app
from app.keystore.service import KeystoreService
from app.ledger_mock import SQLiteLedgerAdapter
@pytest.fixture(name="client")
@ -15,12 +17,24 @@ def client_fixture(tmp_path, monkeypatch):
# Override ledger path to temporary directory
from app.settings import Settings
class TestSettings(Settings):
ledger_db_path = tmp_path / "ledger.db"
test_settings = Settings(LEDGER_DB_PATH=str(tmp_path / "ledger.db"))
monkeypatch.setattr("app.deps.get_settings", lambda: TestSettings())
monkeypatch.setattr("app.settings.settings", test_settings)
from app import deps
deps.get_settings.cache_clear()
deps.get_keystore.cache_clear()
deps.get_ledger.cache_clear()
app = create_app()
keystore = KeystoreService()
ledger = SQLiteLedgerAdapter(Path(test_settings.ledger_db_path))
app.dependency_overrides[get_settings] = lambda: test_settings
app.dependency_overrides[get_keystore] = lambda: keystore
app.dependency_overrides[get_ledger] = lambda: ledger
return TestClient(app)
@ -79,4 +93,6 @@ def test_wallet_password_rules(client: TestClient):
json={"wallet_id": "weak", "password": "short"},
)
assert response.status_code == 400
***
body = response.json()
assert body["detail"]["reason"] == "password_too_weak"
assert "min_length" in body["detail"]

View File

@ -26,6 +26,10 @@
- Implemented CLI/Python runners and execution pipeline with result reporting.
- Added starter tests for runners in `apps/miner-node/tests/test_runners.py`.
## Blockchain Node
- Added websocket fan-out, disconnect cleanup, and load-test coverage in `apps/blockchain-node/tests/test_websocket.py`, ensuring gossip topics deliver reliably to multiple subscribers.
## Directory Preparation
- Established scaffolds for Python and JavaScript packages in `packages/py/` and `packages/js/`.
@ -42,6 +46,7 @@
- Added `apps/wallet-daemon/src/app/receipts/service.py` providing `ReceiptVerifierService` that fetches and validates receipts via `aitbc_sdk`.
- Created unit tests under `apps/wallet-daemon/tests/test_receipts.py` verifying service behavior.
- Implemented wallet SDK receipt ingestion + attestation surfacing in `packages/py/aitbc-sdk/src/receipts.py`, including pagination client, signature verification, and failure diagnostics with full pytest coverage.
- Hardened REST API by wiring dependency overrides in `apps/wallet-daemon/tests/test_wallet_api.py`, expanding workflow coverage (create/list/unlock/sign) and enforcing structured password policy errors consumed in CI.
## Explorer Web

26
docs/ports.md Normal file
View File

@ -0,0 +1,26 @@
# Port Allocation Plan
This document tracks current and planned TCP port assignments across the AITBC devnet stack. Update it whenever new services are introduced or defaults change.
## Current Usage
| Port | Service | Location | Notes |
| --- | --- | --- | --- |
| 8080 | Blockchain RPC API (FastAPI) | `apps/blockchain-node/scripts/devnet_up.sh``python -m uvicorn aitbc_chain.app:app` | Exposes REST/WebSocket RPC endpoints for blocks, transactions, receipts. |
| 8090 | Mock Coordinator API | `apps/blockchain-node/scripts/devnet_up.sh``uvicorn mock_coordinator:app` | Generates synthetic coordinator/miner telemetry consumed by Grafana dashboards. |
| 9090 | Prometheus (planned default) | `apps/blockchain-node/observability/` (targets to be wired) | Scrapes blockchain node + mock coordinator metrics. Ensure firewall allows local-only access. |
| 3000 | Grafana (planned default) | `apps/blockchain-node/observability/grafana-dashboard.json` | Visualizes metrics dashboards; behind devnet Docker compose or local binary. |
## Reserved / Planned Ports
- **Coordinator API (production)** TBD (`8000` suggested). Align with `apps/coordinator-api/README.md` once the service runs outside mock mode.
- **Marketplace Web** Vite dev server defaults to `5173`; document overrides when deploying behind nginx.
- **Explorer Web** Vite dev server defaults to `4173`; ensure it does not collide with other tooling on developer machines.
- **Pool Hub API** Reserve `8100` for the FastAPI service when devnet integration begins.
## Guidance
- Avoid reusing the same port across services in devnet scripts to prevent binding conflicts (recent issues occurred when `8080`/`8090` were already in use).
- For production-grade environments, place HTTP services behind a reverse proxy (nginx/Traefik) and update this table with the external vs. internal port mapping.
- When adding new dashboards or exporters, note both the scrape port (Prometheus) and any UI port (Grafana/others).
- If a port is deprecated, strike it through in this table and add a note describing the migration path.

View File

@ -2,7 +2,27 @@
This roadmap aggregates high-priority tasks derived from the bootstrap specifications in `docs/bootstrap/` and tracks progress across the monorepo. Update this document as milestones evolve.
## Stage 1 — Core Services (MVP)
## Stage 1 — Upcoming Focus Areas
- **Blockchain Node Foundations**
- ✅ Bootstrap module layout in `apps/blockchain-node/src/`.
- ✅ Implement SQLModel schemas and RPC stubs aligned with historical/attested receipts.
- **Explorer Web Enablement**
- ✅ Finish mock integration across all pages and polish styling + mock/live toggle.
- ✅ Begin wiring coordinator endpoints (e.g., `/v1/jobs/{job_id}/receipts`).
- **Marketplace Web Scaffolding**
- ✅ Scaffold Vite/vanilla frontends consuming coordinator receipt history endpoints and SDK examples.
- **Pool Hub Services**
- ✅ Initialize FastAPI project, scoring registry, and telemetry ingestion hooks leveraging coordinator/miner metrics.
- **CI Enhancements**
- ✅ Add blockchain-node tests once available and frontend build/lint checks to `.github/workflows/python-tests.yml` or follow-on workflows.
- ✅ Provide systemd unit + installer scripts under `scripts/` for streamlined deployment.
## Stage 2 — Core Services (MVP)
- **Coordinator API**
- ✅ Scaffold FastAPI project (`apps/coordinator-api/src/app/`).
@ -23,10 +43,10 @@ This roadmap aggregates high-priority tasks derived from the bootstrap specifica
- Introduced hex/enum validation hooks via Pydantic validators to ensure hash integrity and safe persistence.
- ✅ Implement PoA proposer loop with block assembly (`apps/blockchain-node/src/aitbc_chain/consensus/poa.py`).
- ✅ Expose REST RPC endpoints for tx submission, balances, receipts (`apps/blockchain-node/src/aitbc_chain/rpc/router.py`).
- Deliver WebSocket RPC + P2P gossip layer:
- Stand up WebSocket subscription endpoints (`apps/blockchain-node/src/aitbc_chain/rpc/websocket.py`) mirroring REST payloads.
- Implement pub/sub transport for block + transaction gossip backed by an in-memory broker (Starlette `Broadcast` or Redis) with configurable fan-out.
- Add integration tests and load-test harness ensuring gossip convergence and back-pressure handling.
- Deliver WebSocket RPC + P2P gossip layer:
- Stand up WebSocket subscription endpoints (`apps/blockchain-node/src/aitbc_chain/rpc/websocket.py`) mirroring REST payloads.
- Implement pub/sub transport for block + transaction gossip backed by an in-memory broker (Starlette `Broadcast` or Redis) with configurable fan-out.
- Add integration tests and load-test harness ensuring gossip convergence and back-pressure handling.
- ✅ Ship devnet scripts (`apps/blockchain-node/scripts/`).
- ✅ Add observability hooks (JSON logging, Prometheus metrics) and integrate coordinator mock into devnet tooling.
- ⏳ Expand observability dashboards + miner mock integration:
@ -46,21 +66,23 @@ This roadmap aggregates high-priority tasks derived from the bootstrap specifica
- ✅ Provide REST and JSON-RPC endpoints for wallet management and signing (`api_rest.py`, `api_jsonrpc.py`).
- ✅ Add mock ledger adapter with SQLite backend powering event history (`ledger_mock/`).
- ✅ Integrate Python receipt verification helpers (`aitbc_sdk`) and expose API/service utilities validating miner + coordinator signatures.
- ✅ Harden REST API workflows (create/list/unlock/sign) with structured password policy enforcement and deterministic pytest coverage in `apps/wallet-daemon/tests/test_wallet_api.py`.
- ✅ Implement Wallet SDK receipt ingestion + attestation surfacing:
- Added `/v1/jobs/{job_id}/receipts` client helpers with cursor pagination, retry/backoff, and summary reporting (`packages/py/aitbc-sdk/src/receipts.py`).
- Reused crypto helpers to validate miner and coordinator signatures, capturing per-key failure reasons for downstream UX.
- Surfaced aggregated attestation status (`ReceiptStatus`) and failure diagnostics for SDK + UI consumers; JS helper parity still planned.
## Stage 2 — Pool Hub & Marketplace
## Stage 3 — Pool Hub & Marketplace
- **Pool Hub**
- ✅ Implement miner registry, scoring engine, and `/v1/match` API with Redis/PostgreSQL backing stores.
- ✅ Add observability endpoints (`/v1/health`, `/v1/metrics`) plus Prometheus instrumentation and integration tests.
- **Marketplace Web**
- Initialize Vite project with vanilla TypeScript.
- Build offer list, bid form, stats views sourcing mock JSON.
- Provide API abstraction to switch between mock and real backends.
- Initialize Vite project with vanilla TypeScript (`apps/marketplace-web/`).
- Build offer list, bid form, and stats cards powered by mock data fixtures (`public/mock/`).
- Provide API abstraction toggling mock/live mode (`src/lib/api.ts`) and wire coordinator endpoints.
- ⏳ Validate live mode against coordinator `/v1/marketplace/*` responses and add auth feature flags for rollout.
- **Explorer Web**
- ✅ Initialize Vite + TypeScript project scaffold (`apps/explorer-web/`).
@ -74,42 +96,130 @@ This roadmap aggregates high-priority tasks derived from the bootstrap specifica
- Add fallbacks + error surfacing for partial/failed live responses (toast + console diagnostics).
- Audit responsive breakpoints (`public/css/layout.css`) and adjust grid/typography for tablet + mobile; add regression checks in Percy/Playwright snapshots.
## Stage 4 — Observability & Production Polish
- **Observability & Telemetry**
- ⏳ Build Grafana dashboards for PoA consensus health (block intervals, proposer rotation cadence) leveraging `poa_last_block_interval_seconds`, `poa_proposer_rotations_total`, and per-proposer counters.
- ⏳ Surface RPC latency histograms/summaries for critical endpoints (`rpc_get_head`, `rpc_send_tx`, `rpc_submit_receipt`) and add Grafana panels with SLO thresholds.
- ⏳ Ingest miner mock telemetry (job throughput, failure rate) into the shared Prometheus registry and wire panels/alerts that correlate miner health with consensus metrics.
- **Explorer Web (Live Mode)**
- ⏳ Finalize live `getDataMode() === "live"` workflow: align API payload contracts, render loading/error states, and persist mock/live toggle preference.
- ⏳ Expand responsive testing (tablet/mobile) and add automated visual regression snapshots prior to launch.
- ⏳ Integrate Playwright smoke tests covering overview, blocks, and transactions pages in live mode.
- **Marketplace Web (Launch Readiness)**
- ✅ Connect mock listings/bids to coordinator data sources and provide feature flags for live mode rollout.
- ✅ Implement auth/session scaffolding for marketplace actions and document API assumptions in `apps/marketplace-web/README.md`.
- ⏳ Add Grafana panels monitoring marketplace API throughput and error rates once endpoints are live.
- **Operational Hardening**
- ⏳ Extend Alertmanager rules to cover RPC error spikes, proposer stalls, and miner disconnects using the new metrics.
- ⏳ Document dashboard import + alert deployment steps in `docs/run.md` for operators.
- ⏳ Prepare Stage 3 release checklist linking dashboards, alerts, and smoke tests prior to production cutover.
## Stage 5 — Scaling & Release Readiness
- **Infrastructure Scaling**
- ⏳ Benchmark blockchain node throughput under sustained load; capture CPU/memory targets and suggest horizontal scaling thresholds.
- ⏳ Build Terraform/Helm templates for dev/staging/prod environments, including Prometheus/Grafana bundles.
- ⏳ Implement autoscaling policies for coordinator, miners, and marketplace services with synthetic traffic tests.
- **Reliability & Compliance**
- ⏳ Formalize backup/restore procedures for PostgreSQL, Redis, and ledger storage with scheduled jobs.
- ⏳ Complete security hardening review (TLS termination, API auth, secrets management) and document mitigations in `docs/security.md`.
- ⏳ Add chaos testing scripts (network partition, coordinator outage) and track mean-time-to-recovery metrics.
- **Product Launch Checklist**
- ⏳ Finalize public documentation (API references, onboarding guides) and publish to the docs portal.
- ⏳ Coordinate beta release timeline, including user acceptance testing of explorer/marketplace live modes.
- ⏳ Establish post-launch monitoring playbooks and on-call rotations.
## Stage 6 — Ecosystem Expansion
- **Cross-Chain & Interop**
- ⏳ Prototype cross-chain settlement hooks leveraging external bridges; document integration patterns.
- ⏳ Extend SDKs (Python/JS) with pluggable transport abstractions for multi-network support.
- ⏳ Evaluate third-party explorer/analytics integrations and publish partner onboarding guides.
- **Marketplace Growth**
- ⏳ Launch incentive programs (staking, liquidity mining) and expose telemetry dashboards tracking campaign performance.
- ⏳ Implement governance module (proposal voting, parameter changes) and add API/UX flows to explorer/marketplace.
- ⏳ Provide SLA-backed coordinator/pool hubs with capacity planning and billing instrumentation.
- **Developer Experience**
- ⏳ Publish advanced tutorials (custom proposers, marketplace extensions) and maintain versioned API docs.
- ⏳ Integrate CI/CD pipelines with canary deployments and blue/green release automation.
- ⏳ Host quarterly architecture reviews capturing lessons learned and feeding into roadmap revisions.
## Stage 7 — Innovation & Ecosystem Services
- **Advanced Cryptography & Privacy**
- ⏳ Research zk-proof-based receipt attestation and prototype a privacy-preserving settlement flow.
- ⏳ Add confidential transaction support in coordinator/miner stack with opt-in ciphertext storage.
- ⏳ Publish threat modeling updates and share mitigations with ecosystem partners.
- **Enterprise Integrations**
- ⏳ Deliver reference connectors for ERP/payment systems and document SLA expectations.
- ⏳ Stand up multi-tenant coordinator infrastructure with per-tenant isolation and billing metrics.
- ⏳ Launch ecosystem certification program (SDK conformance, security best practices) with public registry.
- **Community & Governance**
- ⏳ Establish open RFC process, publish governance website, and schedule regular community calls.
- ⏳ Sponsor hackathons/accelerators and provide grants for marketplace extensions and analytics tooling.
- ⏳ Track ecosystem KPIs (active marketplaces, cross-chain volume) and feed them into quarterly strategy reviews.
## Stage 8 — Frontier R&D & Global Expansion
- **Protocol Evolution**
- ⏳ Launch research consortium exploring next-gen consensus (hybrid PoA/PoS) and finalize whitepapers.
- ⏳ Prototype sharding or rollup architectures to scale throughput beyond current limits.
- ⏳ Standardize interoperability specs with industry bodies and submit proposals for adoption.
- **Global Rollout**
- ⏳ Establish regional infrastructure hubs (multi-cloud) with localized compliance and data residency guarantees.
- ⏳ Partner with regulators/enterprises to pilot regulated marketplaces and publish compliance playbooks.
- ⏳ Expand localization (UI, documentation, support) covering top target markets.
- **Long-Term Sustainability**
- ⏳ Create sustainability fund for ecosystem maintenance, bug bounties, and community stewardship.
- ⏳ Define succession planning for core teams, including training programs and contributor pathways.
- ⏳ Publish bi-annual roadmap retrospectives assessing KPI alignment and revising long-term goals.
## Stage 9 — Moonshot Initiatives
- **Decentralized Infrastructure**
- ⏳ Transition coordinator/miner roles toward community-governed validator sets with incentive alignment.
- ⏳ Explore decentralized storage/backbone options (IPFS/Filecoin) for ledger and marketplace artifacts.
- ⏳ Prototype fully trustless marketplace settlement leveraging zero-knowledge rollups.
- **AI & Automation**
- ⏳ Integrate AI-driven monitoring/anomaly detection for proposer health, market liquidity, and fraud detection.
- ⏳ Automate incident response playbooks with ChatOps and policy engines.
- ⏳ Launch research into autonomous agent participation (AI agents bidding/offering in the marketplace) and governance implications.
- **Global Standards Leadership**
- ⏳ chair industry working groups defining receipt/marketplace interoperability standards.
- ⏳ Publish annual transparency reports and sustainability metrics for stakeholders.
- ⏳ Engage with academia and open-source foundations to steward long-term protocol evolution.
### Stage 10 — Stewardship & Legacy Planning
- **Open Governance Maturity**
- ⏳ Transition roadmap ownership to community-elected councils with transparent voting and treasury controls.
- ⏳ Codify constitutional documents (mission, values, conflict resolution) and publish public charters.
- ⏳ Implement on-chain governance modules for protocol upgrades and ecosystem-wide decisions.
- **Educational & Outreach Programs**
- ⏳ Fund university partnerships, research chairs, and developer fellowships focused on decentralized marketplace tech.
- ⏳ Create certification tracks and mentorship programs for new validator/operators.
- ⏳ Launch annual global summit and publish proceedings to share best practices across partners.
- **Long-Term Preservation**
- ⏳ Archive protocol specs, governance records, and cultural artifacts in decentralized storage with redundancy.
- ⏳ Establish legal/organizational frameworks to ensure continuity across jurisdictions.
- ⏳ Develop end-of-life/transition plans for legacy components, documenting deprecation strategies and migration tooling.
## Shared Libraries & Examples
the canonical checklist during implementation. Mark completed tasks with ✅ and add dates or links to relevant PRs as development progresses.
- **Python SDK (`packages/py/aitbc-sdk`)**
- ✅ Implement coordinator receipt client + verification helpers (miner + coordinator attestation support).
- ⏳ Extend helpers to pool hub + wallet APIs and typed models:
- Add REST clients for upcoming Pool Hub endpoints (`/v1/match`, `/v1/miners`) and wallet daemon routes (`/v1/wallets`, `/v1/sign`) with retry/backoff helpers.
- Introduce pydantic/SQLModel-derived typed models mirroring `protocols/api/` and `protocols/receipts/` schemas.
- Provide end-to-end tests + examples validating Pool Hub + wallet flows leveraging the coordinator receipt verification primitives.
- **JavaScript SDK (`packages/js/aitbc-sdk`)**
- ✅ Provide fetch-based wrapper for web clients with TypeScript definitions and basic auth helpers.
- **Examples**
- Populate quickstart clients (Python/JS) with working code.
- Add receipt sign/verify samples using finalized schema.
## Tooling & Operations
- **Scripts & CI**
- ✅ Populate `scripts/ci/run_python_tests.sh` to run coordinator, SDK, wallet-daemon pytest suites with shared `PYTHONPATH` scaffolding.
- ✅ Add GitHub Actions workflow `.github/workflows/python-tests.yml` invoking the shared script on pushes/PRs targeting `main`.
- **Configs**
- Author systemd unit files in `configs/systemd/` for each service.
- Provide Nginx snippets in `configs/nginx/` for reverse proxies.
## Tracking
Use this roadmap as the canonical checklist during implementation. Mark completed tasks with ✅ and add dates or links to relevant PRs as development progresses.
## Upcoming Focus Areas
- **Blockchain Node**: bootstrap module layout (`apps/blockchain-node/src/`), implement SQLModel schemas and RPC stubs aligned with historical/attested receipts.
- **Explorer Web**: finish mock integration across all pages, add styling + mock/live toggle, and begin wiring coordinator endpoints (e.g., `/v1/jobs/{job_id}/receipts`).
- Current focus: reuse new overview metrics scaffolding for blocks/transactions detail views and expand coverage to live data mode.
- **Marketplace Web**: scaffold Vite/vanilla frontends with mock integrations consuming the coordinator receipt history endpoints and SDK examples.
- **Pool Hub**: initialize FastAPI project, scoring registry, and telemetry ingestion hooks leveraging coordinator/miner metrics.
- **CI Enhancements**: add blockchain-node tests once available and frontend build/lint checks to `.github/workflows/python-tests.yml` or follow-on workflows.
- ⏳ Add systemd unit and installer scripts under `scripts/`.

View File

@ -233,6 +233,44 @@ These instructions cover the newly scaffolded services. Install dependencies usi
```
(RPC, consensus, and P2P logic still to be implemented.)
### Observability Dashboards & Alerts
1. Generate the starter Grafana dashboards (if not already present):
```bash
cd apps/blockchain-node
PYTHONPATH=src python - <<'PY'
from pathlib import Path
from aitbc_chain.observability.dashboards import generate_default_dashboards
output_dir = Path("observability/generated_dashboards")
output_dir.mkdir(parents=True, exist_ok=True)
generate_default_dashboards(output_dir)
print("Dashboards written to", output_dir)
PY
```
2. Import each JSON file into Grafana (**Dashboards → Import**):
- `apps/blockchain-node/observability/generated_dashboards/coordinator-overview.json`
- `apps/blockchain-node/observability/generated_dashboards/blockchain-node-overview.json`
Select your Prometheus datasource (pointing at `127.0.0.1:8080` and `127.0.0.1:8090`) during import.
3. Ensure Prometheus scrapes both services. Example snippet from `apps/blockchain-node/observability/prometheus.yml`:
```yaml
scrape_configs:
- job_name: "blockchain-node"
static_configs:
- targets: ["127.0.0.1:8080"]
- job_name: "mock-coordinator"
static_configs:
- targets: ["127.0.0.1:8090"]
```
4. Deploy the Alertmanager rules in `apps/blockchain-node/observability/alerts.yml` (proposer stalls, miner errors, receipt drop-offs, RPC error spikes). After modifying rule files, reload Prometheus/Alertmanager:
```bash
systemctl restart prometheus
systemctl restart alertmanager
```
5. Validate by briefly stopping `aitbc-coordinator.service`, confirming Grafana panels pause and the new alerts fire, then restart the service.
## Next Steps
- Flesh out remaining logic per task breakdowns in `docs/*.md` (e.g., capability-aware scheduling, artifact uploads).

View File

@ -0,0 +1,7 @@
Metadata-Version: 2.4
Name: aitbc-crypto
Version: 0.1.0
Summary: AITBC cryptographic utilities
Requires-Python: >=3.11
Requires-Dist: pydantic>=2.7.0
Requires-Dist: pynacl>=1.5.0

View File

@ -0,0 +1,13 @@
pyproject.toml
src/__init__.py
src/receipt.py
src/signing.py
src/aitbc_crypto/__init__.py
src/aitbc_crypto/receipt.py
src/aitbc_crypto/signing.py
src/aitbc_crypto.egg-info/PKG-INFO
src/aitbc_crypto.egg-info/SOURCES.txt
src/aitbc_crypto.egg-info/dependency_links.txt
src/aitbc_crypto.egg-info/requires.txt
src/aitbc_crypto.egg-info/top_level.txt
tests/test_receipt_signing.py

View File

@ -0,0 +1,2 @@
pydantic>=2.7.0
pynacl>=1.5.0

View File

@ -0,0 +1,4 @@
__init__
aitbc_crypto
receipt
signing

View File

@ -27,4 +27,5 @@ run_pytest() {
run_pytest "${PROJECT_ROOT}/apps/coordinator-api/src:${PKG_PATHS}" apps/coordinator-api/tests -q
run_pytest "${PKG_PATHS}" packages/py/aitbc-sdk/tests -q
run_pytest "${PROJECT_ROOT}/apps/miner-node/src:${PKG_PATHS}" apps/miner-node/tests -q
run_pytest "${PROJECT_ROOT}/apps/wallet-daemon/src:${PKG_PATHS}" apps/wallet-daemon/tests -q
run_pytest "${PROJECT_ROOT}/apps/wallet-daemon/src:${PROJECT_ROOT}/apps/blockchain-node/src:${PKG_PATHS}" apps/wallet-daemon/tests -q
run_pytest "${PROJECT_ROOT}/apps/blockchain-node/src:${PKG_PATHS}" apps/blockchain-node/tests/test_websocket.py -q