diff --git a/backend/protocol_rpc/explorer/queries.py b/backend/protocol_rpc/explorer/queries.py index 1c323c461..c8b5e9efb 100644 --- a/backend/protocol_rpc/explorer/queries.py +++ b/backend/protocol_rpc/explorer/queries.py @@ -2,7 +2,7 @@ import base64 import math -from datetime import datetime +from datetime import datetime, timedelta, timezone from typing import Optional from sqlalchemy import func, or_, text @@ -201,6 +201,43 @@ def get_stats(session: Session) -> dict: .all() ) + # Finalized count from the status breakdown + finalized_count = by_status.get("FINALIZED", 0) + + # Average TPS over the last 24 hours + now = datetime.now(timezone.utc) + day_ago = now - timedelta(hours=24) + tx_last_24h = ( + session.query(func.count()) + .select_from(Transactions) + .filter(Transactions.created_at >= day_ago) + .scalar() + or 0 + ) + avg_tps_24h = round(tx_last_24h / 86400, 4) + + # 14-day transaction volume (grouped by date, filled to today) + today = now.date() + fourteen_days_ago = now - timedelta(days=13) # 14 days including today + volume_rows = ( + session.query( + func.date(Transactions.created_at).label("date"), + func.count().label("count"), + ) + .filter(Transactions.created_at >= fourteen_days_ago) + .group_by(func.date(Transactions.created_at)) + .order_by(func.date(Transactions.created_at)) + .all() + ) + counts_by_date = {row.date: row.count for row in volume_rows} + tx_volume_14d = [ + { + "date": (today - timedelta(days=13 - i)).isoformat(), + "count": counts_by_date.get(today - timedelta(days=13 - i), 0), + } + for i in range(14) + ] + return { "totalTransactions": total, "transactionsByStatus": by_status, @@ -211,6 +248,9 @@ def get_stats(session: Session) -> dict: "totalValidators": total_validators, "totalContracts": total_contracts, "appealedTransactions": appealed, + "finalizedTransactions": finalized_count, + "avgTps24h": avg_tps_24h, + "txVolume14d": tx_volume_14d, "recentTransactions": [ _serialize_tx(tx, include_snapshot=False) for tx in recent ], @@ -233,8 +273,14 @@ def get_all_transactions_paginated( ) -> dict: filters = [] if status: + # Support comma-separated status values for multi-status filtering + status_values = [s.strip() for s in status.split(",") if s.strip()] try: - filters.append(Transactions.status == TransactionStatus(status)) + parsed = [TransactionStatus(s) for s in status_values] + if len(parsed) == 1: + filters.append(Transactions.status == parsed[0]) + else: + filters.append(Transactions.status.in_(parsed)) except ValueError: # Invalid status value — return empty results return { @@ -353,34 +399,6 @@ def get_transaction_with_relations(session: Session, tx_hash: str) -> Optional[d # --------------------------------------------------------------------------- -def delete_transaction(session: Session, tx_hash: str) -> Optional[dict]: - tx = session.query(Transactions).filter(Transactions.hash == tx_hash).first() - if not tx: - return None - - child_count = ( - session.query(func.count()) - .select_from(Transactions) - .filter(Transactions.triggered_by_hash == tx_hash) - .scalar() - or 0 - ) - - if child_count > 0: - session.query(Transactions).filter( - Transactions.triggered_by_hash == tx_hash - ).update({"triggered_by_hash": None}, synchronize_session=False) - - session.delete(tx) - session.flush() - - return { - "success": True, - "message": "Transaction deleted successfully", - "childTransactionsUpdated": child_count, - } - - # --------------------------------------------------------------------------- # State # --------------------------------------------------------------------------- @@ -517,10 +535,31 @@ def get_state_with_transactions(session: Session, state_id: str) -> Optional[dic contract_code = _extract_contract_code(session, state_id) + # Find the deploy transaction to get creator info + creator_info = None + deploy_tx = ( + session.query(Transactions) + .filter( + Transactions.to_address == state_id, + Transactions.type == 1, + ) + .order_by(Transactions.created_at.asc()) + .first() + ) + if deploy_tx: + creator_info = { + "creator_address": deploy_tx.from_address, + "deployment_tx_hash": deploy_tx.hash, + "creation_timestamp": ( + deploy_tx.created_at.isoformat() if deploy_tx.created_at else None + ), + } + return { "state": _serialize_state(state), "transactions": [_serialize_tx(tx, include_snapshot=False) for tx in txs], "contract_code": contract_code, + "creator_info": creator_info, } @@ -529,6 +568,129 @@ def get_state_with_transactions(session: Session, state_id: str) -> Optional[dic # --------------------------------------------------------------------------- +def get_address_info(session: Session, address: str) -> Optional[dict]: + """Resolve an address to its type (CONTRACT, VALIDATOR, or ACCOUNT) and return + relevant data.""" + + # 1. Check if it's a contract (exists in CurrentState with a deploy tx) + state = session.query(CurrentState).filter(CurrentState.id == address).first() + if state: + deploy_tx = ( + session.query(Transactions) + .filter( + Transactions.to_address == address, + Transactions.type == 1, + ) + .order_by(Transactions.created_at.asc()) + .first() + ) + if deploy_tx: + # Return full contract detail inline + contract_detail = get_state_with_transactions(session, address) + return { + "type": "CONTRACT", + "address": address, + **(contract_detail or {}), + } + + # 2. Check if it's a validator + validator = session.query(Validators).filter(Validators.address == address).first() + if validator: + return { + "type": "VALIDATOR", + "address": address, + "validator": _serialize_validator(validator), + } + + # 3. Check if there are any transactions involving this address + tx_count = ( + session.query(func.count()) + .select_from(Transactions) + .filter( + or_( + Transactions.from_address == address, + Transactions.to_address == address, + ) + ) + .scalar() + or 0 + ) + if tx_count > 0: + # Get the account's balance from CurrentState if it exists + balance = state.balance if state else 0 + + addr_filter = or_( + Transactions.from_address == address, + Transactions.to_address == address, + ) + + recent_txs = ( + session.query(Transactions) + .options(*_HEAVY_TX_COLUMNS) + .filter(addr_filter) + .order_by(Transactions.created_at.desc()) + .limit(50) + .all() + ) + + first_tx_time = ( + session.query(func.min(Transactions.created_at)) + .filter(addr_filter) + .scalar() + ) + last_tx_time = ( + session.query(func.max(Transactions.created_at)) + .filter(addr_filter) + .scalar() + ) + + return { + "type": "ACCOUNT", + "address": address, + "balance": balance, + "tx_count": tx_count, + "first_tx_time": first_tx_time.isoformat() if first_tx_time else None, + "last_tx_time": last_tx_time.isoformat() if last_tx_time else None, + "transactions": [ + _serialize_tx(tx, include_snapshot=False) for tx in recent_txs + ], + } + + # Also check if it exists as a CurrentState entry without deploy tx (EOA with state) + if state: + return { + "type": "ACCOUNT", + "address": address, + "balance": state.balance, + "tx_count": 0, + "first_tx_time": None, + "last_tx_time": None, + "transactions": [], + } + + return None + + +def get_all_validators( + session: Session, + search: Optional[str] = None, + limit: Optional[int] = None, +) -> dict: + q = session.query(Validators).order_by(Validators.id) + if search: + like = f"%{search}%" + q = q.filter( + or_( + Validators.address.ilike(like), + Validators.provider.ilike(like), + Validators.model.ilike(like), + ) + ) + if limit: + q = q.limit(limit) + return {"validators": [_serialize_validator(v) for v in q.all()]} + + def get_all_providers(session: Session) -> dict: providers = ( session.query(LLMProviderDBModel) diff --git a/backend/protocol_rpc/explorer/router.py b/backend/protocol_rpc/explorer/router.py index c7cde2080..762e82623 100644 --- a/backend/protocol_rpc/explorer/router.py +++ b/backend/protocol_rpc/explorer/router.py @@ -3,11 +3,8 @@ from typing import Annotated, Literal, Optional from fastapi import APIRouter, Depends, HTTPException, Query -from pydantic import BaseModel -from sqlalchemy import text from sqlalchemy.orm import Session -from backend.database_handler.models import Transactions, TransactionStatus, Validators from backend.protocol_rpc.dependencies import get_db_session from . import queries @@ -61,79 +58,43 @@ def get_transaction( return result -class _UpdateStatusBody(BaseModel): - status: str +# --------------------------------------------------------------------------- +# Validators +# --------------------------------------------------------------------------- -@explorer_router.patch("/transactions/{tx_hash}") -def update_transaction( - tx_hash: str, - body: _UpdateStatusBody, +@explorer_router.get("/validators") +def get_validators( session: Annotated[Session, Depends(get_db_session)], + search: Optional[str] = None, + limit: Optional[int] = Query(None, ge=1, le=100), ): - # Validate status enum - try: - new_status = TransactionStatus(body.status) - except ValueError: - valid = [s.value for s in TransactionStatus] - raise HTTPException( - status_code=400, - detail=f"Invalid status. Must be one of: {', '.join(valid)}", - ) from None - - # Simple status update (matches current explorer PATCH behaviour) - result = session.execute( - text( - "UPDATE transactions SET status = CAST(:new_status AS transaction_status) WHERE hash = :hash" - ), - {"hash": tx_hash, "new_status": new_status.value}, - ) + return queries.get_all_validators(session, search=search, limit=limit) - if result.rowcount == 0: - raise HTTPException(status_code=404, detail="Transaction not found") - - session.flush() - # Return the updated transaction - tx = session.query(Transactions).filter(Transactions.hash == tx_hash).first() - return { - "success": True, - "message": "Transaction status updated successfully", - "transaction": queries._serialize_tx(tx), - } +# --------------------------------------------------------------------------- +# Address (unified lookup) +# --------------------------------------------------------------------------- -@explorer_router.delete("/transactions/{tx_hash}") -def delete_transaction( - tx_hash: str, +@explorer_router.get("/address/{address}") +def get_address( + address: str, session: Annotated[Session, Depends(get_db_session)], ): - result = queries.delete_transaction(session, tx_hash) + result = queries.get_address_info(session, address) if result is None: - raise HTTPException(status_code=404, detail="Transaction not found") + raise HTTPException(status_code=404, detail="Address not found") return result # --------------------------------------------------------------------------- -# Validators +# Contracts # --------------------------------------------------------------------------- -@explorer_router.get("/validators") -def get_validators(session: Annotated[Session, Depends(get_db_session)]): - validators = session.query(Validators).order_by(Validators.id).all() - return { - "validators": [queries._serialize_validator(v) for v in validators], - } - - -# --------------------------------------------------------------------------- -# State -# --------------------------------------------------------------------------- - - -@explorer_router.get("/state") -def get_states( +@explorer_router.get("/contracts") +def get_contracts( session: Annotated[Session, Depends(get_db_session)], search: Optional[str] = None, page: int = Query(1, ge=1), @@ -144,17 +105,6 @@ def get_states( return queries.get_all_states(session, search, page, limit, sort_by, sort_order) -@explorer_router.get("/state/{state_id}") -def get_state( - state_id: str, - session: Annotated[Session, Depends(get_db_session)], -): - result = queries.get_state_with_transactions(session, state_id) - if result is None: - raise HTTPException(status_code=404, detail="State not found") - return result - - # --------------------------------------------------------------------------- # Providers # --------------------------------------------------------------------------- diff --git a/explorer/public/genlayer-logo.svg b/explorer/public/genlayer-logo.svg new file mode 100644 index 000000000..c72257dbc --- /dev/null +++ b/explorer/public/genlayer-logo.svg @@ -0,0 +1 @@ + diff --git a/explorer/src/app/DashboardSections.tsx b/explorer/src/app/DashboardSections.tsx new file mode 100644 index 000000000..20cf6b31a --- /dev/null +++ b/explorer/src/app/DashboardSections.tsx @@ -0,0 +1,226 @@ +import { cache } from 'react'; +import Link from 'next/link'; +import { fetchBackend } from '@/lib/fetchBackend'; +import { StatCard } from '@/components/StatCard'; +import { SparklineChart } from '@/components/SparklineChart'; +import { StatusBadge } from '@/components/StatusBadge'; +import { TransactionTable } from '@/components/TransactionTable'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { TRANSACTION_STATUS_DISPLAY_ORDER } from '@/lib/constants'; +import { Transaction, TransactionStatus } from '@/lib/types'; +import { ChevronRight } from 'lucide-react'; + +// --------------------------------------------------------------------------- +// Cached fetcher — React cache() deduplicates within a single render pass, +// so StatCardsSection and ChartsSection share one /stats call. +// --------------------------------------------------------------------------- + +interface StatsData { + totalTransactions: number; + transactionsByStatus: Record; + transactionsByType: Record; + totalValidators: number; + totalContracts: number; + appealedTransactions: number; + finalizedTransactions: number; + avgTps24h: number; + txVolume14d: { date: string; count: number }[]; + recentTransactions: Transaction[]; +} + +const getStats = cache(() => fetchBackend('/stats')); + +// --------------------------------------------------------------------------- +// 1. Stat Cards +// --------------------------------------------------------------------------- + +export async function StatCardsSection() { + const stats = await getStats(); + + return ( +
+ + + + + +
+ ); +} + +// --------------------------------------------------------------------------- +// 2. Charts — status distribution, types, volume +// --------------------------------------------------------------------------- + +export async function ChartsSection() { + const stats = await getStats(); + + const aggregatedTypes = [ + { label: 'Contract Deploy', count: stats.transactionsByType['deploy'] || 0, color: 'bg-orange-500' }, + { label: 'Contract Call', count: stats.transactionsByType['call'] || 0, color: 'bg-violet-500' }, + ]; + + return ( +
+ + + Status Distribution + + +
+ {TRANSACTION_STATUS_DISPLAY_ORDER.map((status) => { + const count = stats.transactionsByStatus[status] || 0; + const percentage = stats.totalTransactions > 0 + ? ((count / stats.totalTransactions) * 100).toFixed(1) + : 0; + + if (count === 0) return null; + + return ( +
+
+ +
+
+
+
+ {count} +
+ ); + })} +
+ + + + + + Transaction Types + + +
+ {aggregatedTypes.map(({ label, count, color }) => { + const percentage = stats.totalTransactions > 0 + ? ((count / stats.totalTransactions) * 100).toFixed(1) + : 0; + + return ( +
+
+ {label} + + {count.toLocaleString()} ({percentage}%) + +
+
+
+
+
+ ); + })} +
+ + + + {stats.txVolume14d && stats.txVolume14d.length > 0 && ( + + +
+ Volume (14d) + + {stats.txVolume14d.reduce((s, d) => s + d.count, 0).toLocaleString()} txs + +
+
+ +
+ d.count)} + width={400} + height={60} + className="w-full" + /> +
+
+ {stats.txVolume14d[0]?.date} + {stats.txVolume14d[stats.txVolume14d.length - 1]?.date} +
+
+
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// 3. Recent Transactions — independent fetch from /transactions +// --------------------------------------------------------------------------- + +interface TransactionsResponse { + transactions: Transaction[]; +} + +export async function RecentTransactionsSection() { + const data = await fetchBackend('/transactions?limit=10'); + + return ( + + +
+ Recent Transactions + +
+
+ + + +
+ ); +} diff --git a/explorer/src/app/address/[addr]/AddressContent.tsx b/explorer/src/app/address/[addr]/AddressContent.tsx new file mode 100644 index 000000000..0f81de7b7 --- /dev/null +++ b/explorer/src/app/address/[addr]/AddressContent.tsx @@ -0,0 +1,296 @@ +'use client'; + +import Link from 'next/link'; +import { formatDistanceToNow, format } from 'date-fns'; + +import { Transaction, Validator, CurrentState } from '@/lib/types'; +import { AddressTransactionTable } from '@/components/AddressTransactionTable'; +import { CopyButton } from '@/components/CopyButton'; +import { AddressDisplay } from '@/components/AddressDisplay'; +import { CodeBlock } from '@/components/CodeBlock'; +import { JsonViewer } from '@/components/JsonViewer'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { formatGenValue } from '@/lib/formatters'; +import { ContractInteraction } from '@/components/ContractInteraction'; +import { StatItem } from '@/components/StatItem'; +import { + ArrowLeft, + Wallet, + Users, + ArrowRightLeft, + Cpu, + Settings, + Clock, + Database, + FileCode, +} from 'lucide-react'; + +interface CreatorInfo { + creator_address: string | null; + deployment_tx_hash: string; + creation_timestamp: string | null; +} + +export interface AddressInfo { + type: 'CONTRACT' | 'VALIDATOR' | 'ACCOUNT'; + address: string; + validator?: Validator; + balance?: number; + tx_count?: number; + first_tx_time?: string | null; + last_tx_time?: string | null; + transactions?: Transaction[]; + state?: CurrentState; + contract_code?: string | null; + creator_info?: CreatorInfo | null; +} + +export function AddressContent({ addr, data }: { addr: string; data: AddressInfo }) { + if (data.type === 'CONTRACT') return ; + if (data.type === 'VALIDATOR' && data.validator) return ; + return ; +} + +// --------------------------------------------------------------------------- +// Account +// --------------------------------------------------------------------------- + +function AccountView({ address, data }: { address: string; data: AddressInfo }) { + const txs = data.transactions || []; + + return ( +
+ } iconBg="bg-blue-100 dark:bg-blue-950" /> + + + +
+ } iconBg="bg-green-100 dark:bg-green-950" label="Balance" value={formatGenValue(data.balance ?? 0)} /> + } iconBg="bg-blue-100 dark:bg-blue-950" label="Transactions" value={String(data.tx_count ?? txs.length)} /> +
+

First Tx

+

+ {data.first_tx_time ? formatDistanceToNow(new Date(data.first_tx_time), { addSuffix: true }) : '-'} +

+
+
+

Latest Tx

+

+ {data.last_tx_time ? formatDistanceToNow(new Date(data.last_tx_time), { addSuffix: true }) : '-'} +

+
+
+
+
+ + + + + + Transactions ({data.tx_count ?? txs.length}) + + + + + + + + +
+ ); +} + +// --------------------------------------------------------------------------- +// Contract +// --------------------------------------------------------------------------- + +function ContractView({ address, data }: { address: string; data: AddressInfo }) { + const state = data.state; + const transactions = data.transactions || []; + const contract_code = data.contract_code; + const creator_info = data.creator_info; + + return ( +
+ } iconBg="bg-purple-100 dark:bg-purple-950" /> + + + +
+ {state && ( + <> + } iconBg="bg-green-100 dark:bg-green-950" label="Balance" value={formatGenValue(state.balance)} /> + } iconBg="bg-blue-100 dark:bg-blue-950" label="Transactions" value={String(data.tx_count ?? transactions.length)} /> + } iconBg="bg-muted" label="Last Updated" value={state.updated_at ? formatDistanceToNow(new Date(state.updated_at), { addSuffix: true }) : 'Unknown'} small /> + + )} + {creator_info && ( + <> +
+

Creator

+ {creator_info.creator_address ? ( + + ) : -} +
+
+

Deploy Tx

+ +
+
+

Created

+

+ {creator_info.creation_timestamp ? format(new Date(creator_info.creation_timestamp), 'PPpp') : '-'} +

+
+ + )} +
+
+
+ + + + + + Transactions ({data.tx_count ?? transactions.length}) + + + + Contract + + {state?.data && Object.keys(state.data).length > 0 && ( + + + State + + )} + + + + + + + + + + + + + {state?.data && Object.keys(state.data).length > 0 && ( + + + +
+ +
+
+
+
+ )} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Validator +// --------------------------------------------------------------------------- + +function ValidatorView({ address, validator }: { address: string; validator: Validator }) { + return ( +
+ } iconBg="bg-emerald-100 dark:bg-emerald-950" /> + +
+ + + } iconBg="bg-green-100 dark:bg-green-950" label="Stake" value={formatGenValue(validator.stake)} /> + + + + +
+
+ +
+
+

Provider / Model

+
+ {validator.provider} + {validator.model} +
+
+
+
+
+ + + } iconBg="bg-muted" label="Created" value={validator.created_at ? formatDistanceToNow(new Date(validator.created_at), { addSuffix: true }) : 'Unknown'} small /> + + +
+ + {validator.config && Object.keys(validator.config).length > 0 && ( + + + + + Configuration + + + +
+              {JSON.stringify(validator.config, null, 2)}
+            
+
+
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Shared primitives +// --------------------------------------------------------------------------- + +function AddressHeader({ title, address, backHref, icon, iconBg }: { + title: string; + address: string; + backHref: string; + icon: React.ReactNode; + iconBg: string; +}) { + return ( +
+ +
+
+

{title}

+
+ {address} + +
+
+
{icon}
+
+
+ ); +} + diff --git a/explorer/src/app/address/[addr]/loading.tsx b/explorer/src/app/address/[addr]/loading.tsx new file mode 100644 index 000000000..98ee1fef7 --- /dev/null +++ b/explorer/src/app/address/[addr]/loading.tsx @@ -0,0 +1,9 @@ +import { Loader2 } from 'lucide-react'; + +export default function Loading() { + return ( +
+ +
+ ); +} diff --git a/explorer/src/app/address/[addr]/page.tsx b/explorer/src/app/address/[addr]/page.tsx new file mode 100644 index 000000000..a0bb626d9 --- /dev/null +++ b/explorer/src/app/address/[addr]/page.tsx @@ -0,0 +1,36 @@ +import Link from 'next/link'; +import { fetchBackend } from '@/lib/fetchBackend'; +import { AddressContent, type AddressInfo } from './AddressContent'; +import { Card, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { ArrowLeft } from 'lucide-react'; + +export default async function AddressPage({ params }: { params: Promise<{ addr: string }> }) { + const { addr } = await params; + + try { + const data = await fetchBackend( + `/address/${encodeURIComponent(addr)}`, + ); + return ; + } catch (err) { + return ( +
+ + + +

Error

+

+ {err instanceof Error ? err.message : 'Unknown error'} +

+
+
+
+ ); + } +} diff --git a/explorer/src/app/api/rpc/route.ts b/explorer/src/app/api/rpc/route.ts new file mode 100644 index 000000000..c71477285 --- /dev/null +++ b/explorer/src/app/api/rpc/route.ts @@ -0,0 +1,14 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:4000'; + +export async function POST(request: NextRequest) { + const body = await request.json(); + const res = await fetch(`${BACKEND_URL}/api`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await res.json(); + return NextResponse.json(data); +} diff --git a/explorer/src/app/contracts/[id]/page.tsx b/explorer/src/app/contracts/[id]/page.tsx index 52f248d2d..43716403a 100644 --- a/explorer/src/app/contracts/[id]/page.tsx +++ b/explorer/src/app/contracts/[id]/page.tsx @@ -1,295 +1,6 @@ -'use client'; +import { redirect } from 'next/navigation'; -import { useEffect, useState, use } from 'react'; -import Link from 'next/link'; -import { formatDistanceToNow, format } from 'date-fns'; - -import { CurrentState, Transaction } from '@/lib/types'; -import { StatusBadge } from '@/components/StatusBadge'; -import { JsonViewer } from '@/components/JsonViewer'; -import { CodeBlock } from '@/components/CodeBlock'; -import { CopyButton } from '@/components/CopyButton'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; -import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table'; -import { truncateAddress, formatGenValue } from '@/lib/formatters'; -import { - ArrowLeft, - Loader2, - Database, - Wallet, - Clock, - ArrowRightLeft, - FileCode, -} from 'lucide-react'; - -interface StateDetail { - state: CurrentState; - transactions: Transaction[]; - contract_code: string | null; -} - -export default function StateDetailPage({ params }: { params: Promise<{ id: string }> }) { - const { id } = use(params); - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - async function fetchState() { - try { - const res = await fetch(`/api/state/${encodeURIComponent(id)}`); - if (!res.ok) { - if (res.status === 404) throw new Error('State not found'); - throw new Error('Failed to fetch state'); - } - const data = await res.json(); - setData(data); - } catch (err) { - setError(err instanceof Error ? err.message : 'Unknown error'); - } finally { - setLoading(false); - } - } - fetchState(); - }, [id]); - - if (loading) { - return ( -
- -
- ); - } - - if (error) { - return ( -
- - - -

Error

-

{error}

-
-
-
- ); - } - - if (!data) return null; - - const { state, transactions, contract_code } = data; - - return ( -
-
- -
-
-

Contract State

-
- {state.id} - -
-
-
- -
-
-
- -
- - -
-
- -
-
-

Balance

-

{formatGenValue(state.balance)}

-
-
-
-
- - -
-
- -
-
-

Related Transactions

-

{transactions.length}

-
-
-
-
- - -
-
- -
-
-

Last Updated

-

- {state.updated_at - ? formatDistanceToNow(new Date(state.updated_at), { addSuffix: true }) - : 'Unknown'} -

-
-
-
-
-
- - {contract_code && ( - - - - - Contract Source Code - - - - - - - )} - - - - - - State Data - - - - {state.data && Object.keys(state.data).length > 0 ? ( -
- -
- ) : ( -
No state data available
- )} -
-
- - - - - - Related Transactions - - - - {transactions.length > 0 ? ( - - - - Hash - Status - Direction - From - To - Nonce - Value - Time - - - - {transactions.map((tx) => { - const isIncoming = tx.to_address === state.id; - const isOutgoing = tx.from_address === state.id; - - return ( - - -
- - {tx.hash.slice(0, 10)}...{tx.hash.slice(-8)} - - -
-
- - - - -
- {isIncoming && ( - - IN - - )} - {isOutgoing && ( - - OUT - - )} -
-
- - {tx.from_address ? ( -
- - {truncateAddress(tx.from_address)} - - -
- ) : ( - - - )} -
- - {tx.to_address ? ( -
- - {truncateAddress(tx.to_address)} - - -
- ) : ( - - - )} -
- - {tx.nonce !== null ? tx.nonce : '-'} - - - {formatGenValue(tx.value)} - - - {tx.created_at - ? format(new Date(tx.created_at), 'PPpp') - : '-'} - -
- ); - })} -
-
- ) : ( -
- No related transactions found -
- )} -
-
-
- ); +export default async function ContractDetailPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + redirect(`/address/${id}`); } diff --git a/explorer/src/app/contracts/page.tsx b/explorer/src/app/contracts/page.tsx index 364603fa0..50fc6be9c 100644 --- a/explorer/src/app/contracts/page.tsx +++ b/explorer/src/app/contracts/page.tsx @@ -1,17 +1,17 @@ 'use client'; import { useEffect, useState, useCallback, Suspense } from 'react'; -import { useSearchParams, useRouter } from 'next/navigation'; -import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; +import { usePagination } from '@/hooks/usePagination'; import { CurrentState } from '@/lib/types'; -import { CopyButton } from '@/components/CopyButton'; +import { AddressDisplay } from '@/components/AddressDisplay'; import { Card, CardContent } from '@/components/ui/card'; import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table'; import { Button } from '@/components/ui/button'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Loader2, ChevronLeft, ChevronRight, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react'; import { formatDistanceToNow } from 'date-fns'; -import { formatGenValue, truncateAddress } from '@/lib/formatters'; +import { formatGenValue } from '@/lib/formatters'; interface StatesResponse { states: CurrentState[]; @@ -26,14 +26,12 @@ interface StatesResponse { const PAGE_SIZE_OPTIONS = [12, 24, 48, 96] as const; function StateContent() { - const router = useRouter(); const searchParams = useSearchParams(); + const { page, limit, updateParams } = usePagination(searchParams, 24); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const page = parseInt(searchParams.get('page') || '1', 10) || 1; - const limit = parseInt(searchParams.get('limit') || '24', 10) || 24; const sortBy = searchParams.get('sort_by') || ''; const sortOrder = searchParams.get('sort_order') || 'desc'; @@ -47,7 +45,7 @@ function StateContent() { if (sortBy) params.set('sort_by', sortBy); if (sortBy) params.set('sort_order', sortOrder); - const res = await fetch(`/api/state?${params.toString()}`); + const res = await fetch(`/api/contracts?${params.toString()}`); if (!res.ok) throw new Error('Failed to fetch states'); const data = await res.json(); setData(data); @@ -62,18 +60,6 @@ function StateContent() { fetchStates(); }, [fetchStates]); - const updateParams = (updates: Record) => { - const params = new URLSearchParams(searchParams.toString()); - Object.entries(updates).forEach(([key, value]) => { - if (value === null || value === '') { - params.delete(key); - } else { - params.set(key, value); - } - }); - router.push(`/contracts?${params.toString()}`); - }; - const toggleSort = (column: string) => { if (sortBy === column) { updateParams({ sort_order: sortOrder === 'desc' ? 'asc' : 'desc', page: '1' }); @@ -144,12 +130,13 @@ function StateContent() { data.states.map((state) => ( -
- - {truncateAddress(state.id, 10, 8)} - - -
+
{formatGenValue(state.balance)} diff --git a/explorer/src/app/favicon.ico b/explorer/src/app/favicon.ico deleted file mode 100644 index 718d6fea4..000000000 Binary files a/explorer/src/app/favicon.ico and /dev/null differ diff --git a/explorer/src/app/globals.css b/explorer/src/app/globals.css index 87e1a72dd..d0a7f574e 100644 --- a/explorer/src/app/globals.css +++ b/explorer/src/app/globals.css @@ -1,3 +1,4 @@ +@import url('https://api.fontshare.com/v2/css?f[]=switzer@400,500,600,700&display=swap'); @import "tailwindcss"; @custom-variant dark (&:where(.dark, .dark *)); diff --git a/explorer/src/app/icon.png b/explorer/src/app/icon.png new file mode 100644 index 000000000..034d0abbc Binary files /dev/null and b/explorer/src/app/icon.png differ diff --git a/explorer/src/app/layout.tsx b/explorer/src/app/layout.tsx index 14e3443c5..7b4964b2b 100644 --- a/explorer/src/app/layout.tsx +++ b/explorer/src/app/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { Suspense } from "react"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import { Navigation } from "@/components/Navigation"; @@ -17,7 +18,7 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Studio Explorer - GenLayer State Browser", + title: "GenLayer Studio Explorer", description: "Browse and explore GenLayer transaction and consensus data", }; @@ -34,7 +35,9 @@ export default function RootLayout({ - + + +
{children}
diff --git a/explorer/src/app/loading.tsx b/explorer/src/app/loading.tsx new file mode 100644 index 000000000..98ee1fef7 --- /dev/null +++ b/explorer/src/app/loading.tsx @@ -0,0 +1,9 @@ +import { Loader2 } from 'lucide-react'; + +export default function Loading() { + return ( +
+ +
+ ); +} diff --git a/explorer/src/app/page.tsx b/explorer/src/app/page.tsx index d886d21a8..52b7b9a0c 100644 --- a/explorer/src/app/page.tsx +++ b/explorer/src/app/page.tsx @@ -1,224 +1,45 @@ -'use client'; +import { Suspense } from 'react'; +import { StatCardsSection, ChartsSection, RecentTransactionsSection } from './DashboardSections'; -import { useEffect, useState } from 'react'; -import Link from 'next/link'; -import { StatusBadge } from '@/components/StatusBadge'; -import { TransactionTable } from '@/components/TransactionTable'; -import { StatCard } from '@/components/StatCard'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; -import { Transaction, TransactionStatus } from '@/lib/types'; -import { TRANSACTION_STATUS_DISPLAY_ORDER } from '@/lib/constants'; -import { - ArrowRightLeft, - Users, - Database, - AlertTriangle, - TrendingUp, - Clock, - Loader2, - ChevronRight, -} from 'lucide-react'; - -interface Stats { - totalTransactions: number; - transactionsByStatus: Record; - transactionsByType: Record; - totalValidators: number; - totalContracts: number; - appealedTransactions: number; - recentTransactions: Transaction[]; +function CardSkeleton({ className = '' }: { className?: string }) { + return
; } -export default function Dashboard() { - const [stats, setStats] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - async function fetchStats() { - try { - const res = await fetch('/api/stats'); - if (!res.ok) throw new Error('Failed to fetch stats'); - const data = await res.json(); - setStats(data); - } catch (err) { - setError(err instanceof Error ? err.message : 'Unknown error'); - } finally { - setLoading(false); - } - } - fetchStats(); - }, []); - - if (loading) { - return ( -
- -
- ); - } - - if (error) { - return ( - - -

Error loading dashboard

-

{error}

-
-
- ); - } - - if (!stats) return null; - - const aggregatedTypes: { label: string; count: number; color: string }[] = [ - { label: 'Contract Deploy', count: stats.transactionsByType['deploy'] || 0, color: 'bg-orange-500' }, - { label: 'Contract Call', count: stats.transactionsByType['call'] || 0, color: 'bg-violet-500' }, - ]; - +export default function DashboardPage() { return ( -
-
+
+

Dashboard

-

Overview of GenLayer state and transactions

+

Overview of GenLayer state and transactions

-
- - - - -
- -
- - -
-
- -
- Transaction Status Distribution -
-
- -
- {TRANSACTION_STATUS_DISPLAY_ORDER.map((status) => { - const count = stats.transactionsByStatus[status] || 0; - const percentage = stats.totalTransactions > 0 - ? ((count / stats.totalTransactions) * 100).toFixed(1) - : 0; - - if (count === 0) return null; - - return ( -
-
- -
-
-
-
- {count} -
- ); - })} -
- - - - - -
-
- -
- Transaction Types -
-
- -
- {aggregatedTypes.map(({ label, count, color }) => { - const percentage = stats.totalTransactions > 0 - ? ((count / stats.totalTransactions) * 100).toFixed(1) - : 0; - - return ( -
-
- {label} - - {count.toLocaleString()} ({percentage}%) - -
-
-
-
-
- ); - })} -
- - -
- - - -
-
-
- -
- Recent Transactions -
- + + {Array.from({ length: 5 }, (_, i) => ( + + ))} +
+ } + > + + + + + {Array.from({ length: 3 }, (_, i) => ( + + ))}
- - - - -
+ } + > + + + + }> + +
); } diff --git a/explorer/src/app/providers/ProvidersContent.tsx b/explorer/src/app/providers/ProvidersContent.tsx new file mode 100644 index 000000000..bd263172c --- /dev/null +++ b/explorer/src/app/providers/ProvidersContent.tsx @@ -0,0 +1,187 @@ +'use client'; + +import { useState } from 'react'; +import { JsonViewer } from '@/components/JsonViewer'; +import { LLMProvider } from '@/lib/types'; +import { Card, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { Cpu, Star, Settings, Clock } from 'lucide-react'; +import { format } from 'date-fns'; + +export function ProvidersContent({ providers }: { providers: LLMProvider[] }) { + const [expandedId, setExpandedId] = useState(null); + + const groupedProviders = providers.reduce((acc, provider) => { + if (!acc[provider.provider]) { + acc[provider.provider] = []; + } + acc[provider.provider].push(provider); + return acc; + }, {} as Record); + + return ( +
+
+

LLM Providers

+

Configured language model providers and their settings

+
+ +
+ + +
+
+ +
+
+

Total Providers

+

{providers.length}

+
+
+
+
+ + +
+
+ +
+
+

Provider Types

+

{Object.keys(groupedProviders).length}

+
+
+
+
+ + +
+
+ +
+
+

Default Providers

+

{providers.filter(p => p.is_default).length}

+
+
+
+
+
+ + {Object.entries(groupedProviders).map(([providerName, providerList]) => ( +
+

+ + {providerName} + + ({providerList.length} model{providerList.length !== 1 ? 's' : ''}) + +

+ +
+ {providerList.map((provider) => ( + setExpandedId(open ? provider.id : null)} + > + + +
+
+ {provider.model} + {provider.is_default && ( + + + Default + + )} +
+
+
+ {provider.plugin} + +
+
+ + +
+
+
+

Details

+
+
+ ID + {provider.id} +
+
+ Provider + {provider.provider} +
+
+ Model + {provider.model} +
+
+ Plugin + {provider.plugin} +
+
+ Default + {provider.is_default ? 'Yes' : 'No'} +
+
+ Created + + + {provider.created_at ? format(new Date(provider.created_at), 'PPpp') : '-'} + +
+
+ Updated + + + {provider.updated_at ? format(new Date(provider.updated_at), 'PPpp') : '-'} + +
+
+
+
+ {provider.config && (typeof provider.config === 'object' ? Object.keys(provider.config).length > 0 : provider.config) && ( +
+

Config

+
+ +
+
+ )} + {provider.plugin_config && Object.keys(provider.plugin_config).length > 0 && ( +
+

Plugin Config

+
+ +
+
+ )} +
+
+
+
+
+
+ ))} +
+
+ ))} + + {providers.length === 0 && ( + + + No LLM providers configured + + + )} +
+ ); +} diff --git a/explorer/src/app/providers/loading.tsx b/explorer/src/app/providers/loading.tsx new file mode 100644 index 000000000..98ee1fef7 --- /dev/null +++ b/explorer/src/app/providers/loading.tsx @@ -0,0 +1,9 @@ +import { Loader2 } from 'lucide-react'; + +export default function Loading() { + return ( +
+ +
+ ); +} diff --git a/explorer/src/app/providers/page.tsx b/explorer/src/app/providers/page.tsx index 4e3c9a335..dbc3a7555 100644 --- a/explorer/src/app/providers/page.tsx +++ b/explorer/src/app/providers/page.tsx @@ -1,225 +1,22 @@ -'use client'; - -import { useEffect, useState } from 'react'; -import { JsonViewer } from '@/components/JsonViewer'; +import { fetchBackend } from '@/lib/fetchBackend'; import { LLMProvider } from '@/lib/types'; +import { ProvidersContent } from './ProvidersContent'; import { Card, CardContent } from '@/components/ui/card'; -import { Badge } from '@/components/ui/badge'; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; -import { Loader2, Cpu, Star, Settings, Clock } from 'lucide-react'; -import { format } from 'date-fns'; - -export default function ProvidersPage() { - const [providers, setProviders] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [expandedId, setExpandedId] = useState(null); - - useEffect(() => { - async function fetchProviders() { - try { - const res = await fetch('/api/providers'); - if (!res.ok) throw new Error('Failed to fetch providers'); - const data = await res.json(); - setProviders(data.providers); - } catch (err) { - setError(err instanceof Error ? err.message : 'Unknown error'); - } finally { - setLoading(false); - } - } - fetchProviders(); - }, []); - - if (loading) { - return ( -
- -
- ); - } - if (error) { +export default async function ProvidersPage() { + try { + const data = await fetchBackend<{ providers: LLMProvider[] }>('/providers'); + return ; + } catch (err) { return (

Error loading providers

-

{error}

+

+ {err instanceof Error ? err.message : 'Unknown error'} +

); } - - const groupedProviders = providers.reduce((acc, provider) => { - if (!acc[provider.provider]) { - acc[provider.provider] = []; - } - acc[provider.provider].push(provider); - return acc; - }, {} as Record); - - return ( -
-
-

LLM Providers

-

Configured language model providers and their settings

-
- -
- - -
-
- -
-
-

Total Providers

-

{providers.length}

-
-
-
-
- - -
-
- -
-
-

Provider Types

-

{Object.keys(groupedProviders).length}

-
-
-
-
- - -
-
- -
-
-

Default Providers

-

{providers.filter(p => p.is_default).length}

-
-
-
-
-
- - {Object.entries(groupedProviders).map(([providerName, providerList]) => ( -
-

- - {providerName} - - ({providerList.length} model{providerList.length !== 1 ? 's' : ''}) - -

- -
- {providerList.map((provider) => ( - setExpandedId(open ? provider.id : null)} - > - - -
-
- {provider.model} - {provider.is_default && ( - - - Default - - )} -
-
-
- {provider.plugin} - -
-
- - -
-
-
-

Details

-
-
- ID - {provider.id} -
-
- Provider - {provider.provider} -
-
- Model - {provider.model} -
-
- Plugin - {provider.plugin} -
-
- Default - {provider.is_default ? 'Yes' : 'No'} -
-
- Created - - - {format(new Date(provider.created_at), 'PPpp')} - -
-
- Updated - - - {format(new Date(provider.updated_at), 'PPpp')} - -
-
-
-
- {provider.config && (typeof provider.config === 'object' ? Object.keys(provider.config).length > 0 : provider.config) && ( -
-

Config

-
- -
-
- )} - {provider.plugin_config && Object.keys(provider.plugin_config).length > 0 && ( -
-

Plugin Config

-
- -
-
- )} -
-
-
-
-
-
- ))} -
-
- ))} - - {providers.length === 0 && ( - - - No LLM providers configured - - - )} -
- ); } diff --git a/explorer/src/app/transactions/[hash]/components/DataTab.tsx b/explorer/src/app/transactions/[hash]/components/DataTab.tsx index e4690402e..cf090a54b 100644 --- a/explorer/src/app/transactions/[hash]/components/DataTab.tsx +++ b/explorer/src/app/transactions/[hash]/components/DataTab.tsx @@ -3,6 +3,7 @@ import { Transaction } from '@/lib/types'; import { JsonViewer } from '@/components/JsonViewer'; import { DataDecodePanel } from '@/components/DataDecodePanel'; +import { InputDataPanel } from '@/components/InputDataPanel'; import { User, FileCode } from 'lucide-react'; interface DataTabProps { @@ -10,6 +11,10 @@ interface DataTabProps { } export function DataTab({ transaction: tx }: DataTabProps) { + const calldataB64 = (tx.type === 1 || tx.type === 2) && tx.data && typeof tx.data === 'object' + ? (tx.data as Record).calldata as string | undefined + : undefined; + return (
{tx.input_data && ( @@ -24,7 +29,19 @@ export function DataTab({ transaction: tx }: DataTabProps) {
)} - {tx.data && ( + {/* Etherscan-style input data panel for call transactions */} + {calldataB64 && ( +
+

+ + Input Data +

+ +
+ )} + + {/* Generic data panel for non-call transactions */} + {tx.data && !calldataB64 && (

diff --git a/explorer/src/app/transactions/[hash]/components/MonitoringTab.tsx b/explorer/src/app/transactions/[hash]/components/MonitoringTab.tsx index 6d24a04c1..b92f653e3 100644 --- a/explorer/src/app/transactions/[hash]/components/MonitoringTab.tsx +++ b/explorer/src/app/transactions/[hash]/components/MonitoringTab.tsx @@ -1,24 +1,14 @@ 'use client'; -import { format } from 'date-fns'; import { Transaction, ConsensusHistoryEntry } from '@/lib/types'; -import { JsonViewer } from '@/components/JsonViewer'; -import { MonitoringTimeline } from '@/components/MonitoringTimeline'; import { Card, CardContent } from '@/components/ui/card'; -import { Badge } from '@/components/ui/badge'; -import { isNewConsensusFormat, getConsensusRoundCount } from '@/lib/consensusUtils'; -import { ConsensusJourney } from '@/components/ConsensusJourney'; -import { - Activity, - Users, - Timer, - AlertTriangle, - CheckCircle, - XCircle, - Clock, - Check, - Crown, -} from 'lucide-react'; +import { isNewConsensusFormat } from '@/lib/consensusUtils'; +import { Activity } from 'lucide-react'; +import { MonitoringStats } from '@/components/monitoring/MonitoringStats'; +import { NewFormatTimeline } from '@/components/monitoring/NewFormatTimeline'; +import { LegacyFormatTimeline } from '@/components/monitoring/LegacyFormatTimeline'; +import { TimingInformation } from '@/components/monitoring/TimingInformation'; +import { AppealProcessing } from '@/components/monitoring/AppealProcessing'; interface MonitoringTabProps { transaction: Transaction; @@ -27,84 +17,17 @@ interface MonitoringTabProps { export function MonitoringTab({ transaction: tx }: MonitoringTabProps) { return (
- - -

Transaction Journey

- -
-
- -
- - -
-
- -
-
-

Consensus Rounds

-

- {getConsensusRoundCount(tx.consensus_history)} -

-
-
-
-
- - -
-
- -
-
-

Validators

-

- {tx.num_of_initial_validators || '-'} -

-
-
-
-
- - -
-
- -
-
-

Rotations

-

- {tx.rotation_count ?? 0} -

-
-
-
-
- - -
-
- {tx.appealed ? ( - - ) : ( - - )} -
-
-

Appeal Status

-

- {tx.appealed ? 'Appealed' : 'None'} -

-
-
-
-
-
+ {tx.consensus_history && isNewConsensusFormat(tx.consensus_history) ? ( - + ) : tx.consensus_history && Array.isArray(tx.consensus_history) && tx.consensus_history.length > 0 ? ( - + ) : ( @@ -118,366 +41,19 @@ export function MonitoringTab({ transaction: tx }: MonitoringTabProps) { )} {(tx.last_vote_timestamp || tx.timestamp_appeal || tx.timestamp_awaiting_finalization) && ( - + )} {tx.appeal_processing_time !== null && tx.appeal_processing_time !== undefined && ( - - )} -
- ); -} - -function NewFormatTimeline({ tx }: { tx: Transaction }) { - if (!tx.consensus_history || !isNewConsensusFormat(tx.consensus_history)) return null; - - const consensusHistory = tx.consensus_history; - const firstRound = consensusHistory.consensus_results[0]; - const globalStartTime = firstRound?.monitoring?.PENDING; - - return ( -
- {consensusHistory.current_status_changes.length > 0 && ( -
-

- - Current Status -

-
- {consensusHistory.current_status_changes.map((status, idx) => ( - - {status} - - ))} -
-
+ )} - - {Object.keys(consensusHistory.current_monitoring).length > 0 && ( - - - - - - )} - - {consensusHistory.consensus_results.length > 0 && ( -
-

- - Consensus Rounds ({consensusHistory.consensus_results.length}) -

-
-
- -
- {consensusHistory.consensus_results.map((result, idx) => { - const isFinal = result.consensus_round === 'Accepted' || result.consensus_round === 'Finalized'; - - return ( -
-
- {isFinal && } -
- - -
-
- - Round {idx + 1} - - - {result.consensus_round} - -
-
- - {result.status_changes.length > 0 && ( -
-
Status Flow
-
- {result.status_changes.map((status, sIdx) => ( -
- - {status} - - {sIdx < result.status_changes.length - 1 && ( - - )} -
- ))} -
-
- )} - - {Object.keys(result.monitoring).length > 0 && ( -
- -
- )} - - {result.validator_results.length > 0 && ( -
-
Validator Results
-
- -
-
- )} -
-
- ); - })} -
-
-
- )} -
- ); -} - -function LegacyFormatTimeline({ tx }: { tx: Transaction }) { - if (!tx.consensus_history || !Array.isArray(tx.consensus_history)) return null; - - const consensusHistory = tx.consensus_history as ConsensusHistoryEntry[]; - - return ( -
-

- - Consensus Timeline -

-
-
- -
- {consensusHistory.map((round, idx) => { - const votes = round.votes || []; - const agreeCount = votes.filter((v) => v.vote === 'agree').length; - const disagreeCount = votes.filter((v) => v.vote === 'disagree').length; - const timeoutCount = votes.filter((v) => v.vote === 'timeout').length; - const totalVotes = votes.length; - const leader = round.leader; - const isFinal = round.final; - - return ( -
-
- {isFinal && } -
- - -
-
- - Round {(round.round as number) ?? idx + 1} - - {isFinal && ( - - Final - - )} -
- {totalVotes > 0 && ( -
- - - {agreeCount} - - - - {disagreeCount} - - - - {timeoutCount} - -
- )} -
- - {leader && ( -
-
- - Leader -
-
- {leader.address && ( -
- Address: - - {String(leader.address).slice(0, 10)}...{String(leader.address).slice(-8)} - -
- )} - {leader.mode && ( -
- Mode: - {String(leader.mode)} -
- )} -
-
- )} - - {totalVotes > 0 && ( -
-
- Vote Distribution - {totalVotes} votes -
-
- {agreeCount > 0 && ( -
- )} - {disagreeCount > 0 && ( -
- )} - {timeoutCount > 0 && ( -
- )} -
-
- )} - - {votes.length > 0 && ( -
-
Validator Votes
-
- {votes.map((vote, vIdx) => ( -
- {vote.vote === 'agree' && } - {vote.vote === 'disagree' && } - {vote.vote === 'timeout' && } - - {vote.validator_address - ? `${vote.validator_address.slice(0, 6)}...${vote.validator_address.slice(-4)}` - : `Validator ${vIdx + 1}`} - -
- ))} -
-
- )} - -
- ); - })} -
-
-
- ); -} - -function TimingInformation({ tx }: { tx: Transaction }) { - const formatTs = (ts: number | null | undefined): string => { - if (ts === null || ts === undefined) return '-'; - const tsNum = Number(ts); - const date = new Date(tsNum < 1e12 ? tsNum * 1000 : tsNum); - return isNaN(date.getTime()) ? String(ts) : format(date, 'PPpp'); - }; - - return ( -
-

- - Timing Information -

- - -
- {tx.last_vote_timestamp !== null && tx.last_vote_timestamp !== undefined && ( -
-

Last Vote

-

{formatTs(tx.last_vote_timestamp)}

-
- )} - {tx.timestamp_appeal !== null && tx.timestamp_appeal !== undefined && ( -
-

Appeal Timestamp

-

{formatTs(tx.timestamp_appeal)}

-
- )} - {tx.timestamp_awaiting_finalization !== null && tx.timestamp_awaiting_finalization !== undefined && ( -
-

Awaiting Finalization

-

{formatTs(tx.timestamp_awaiting_finalization)}

-
- )} -
-
-
-
- ); -} - -function AppealProcessing({ tx }: { tx: Transaction }) { - return ( -
-

- - Appeal Processing -

- - -
-
-

Processing Time

-

{tx.appeal_processing_time}ms

-
- {tx.appeal_failed !== null && tx.appeal_failed !== undefined && ( -
-

Failed Appeals

-

{tx.appeal_failed}

-
- )} -
-
-
); } diff --git a/explorer/src/app/transactions/[hash]/components/OverviewTab.tsx b/explorer/src/app/transactions/[hash]/components/OverviewTab.tsx index 80932865b..8fcf90b5a 100644 --- a/explorer/src/app/transactions/[hash]/components/OverviewTab.tsx +++ b/explorer/src/app/transactions/[hash]/components/OverviewTab.tsx @@ -5,11 +5,13 @@ import { format } from 'date-fns'; import { Transaction } from '@/lib/types'; import { StatusBadge } from '@/components/StatusBadge'; import { TransactionTypeLabel } from '@/components/TransactionTypeLabel'; +import { ConsensusJourney } from '@/components/ConsensusJourney'; import { InfoRow } from '@/components/InfoRow'; import { Badge } from '@/components/ui/badge'; import { JsonViewer } from '@/components/JsonViewer'; import { getExecutionResult } from '@/lib/transactionUtils'; import { resultStatusLabel, type DecodedResult } from '@/lib/resultDecoder'; +import { InputDataPanel } from '@/components/InputDataPanel'; import { formatGenValue } from '@/lib/formatters'; interface OverviewTabProps { @@ -94,6 +96,10 @@ export function OverviewTab({ transaction: tx }: OverviewTabProps) { const decodedResult = execResult?.decodedResult; const eqOutputs = execResult?.eqOutputs; + const calldataB64 = (tx.type === 1 || tx.type === 2) && tx.data && typeof tx.data === 'object' + ? (tx.data as Record).calldata as string | undefined + : undefined; + return (
@@ -103,7 +109,7 @@ export function OverviewTab({ transaction: tx }: OverviewTabProps) { label="From" value={ tx.from_address ? ( - + {tx.from_address} ) : ( @@ -117,7 +123,7 @@ export function OverviewTab({ transaction: tx }: OverviewTabProps) { label="To" value={ tx.to_address ? ( - + {tx.to_address} ) : ( @@ -150,6 +156,13 @@ export function OverviewTab({ transaction: tx }: OverviewTabProps) { {tx.worker_id && } + {calldataB64 && ( +
+

Input Data

+ +
+ )} + {/* Execution Result Section */} {(executionResult || genvmResult || decodedResult) && ( <> @@ -237,6 +250,11 @@ export function OverviewTab({ transaction: tx }: OverviewTabProps) {
)} + + {/* Transaction Journey */} +
+ +
); } diff --git a/explorer/src/app/transactions/[hash]/page.tsx b/explorer/src/app/transactions/[hash]/page.tsx index 9bf8fdb39..7b0e04e2f 100644 --- a/explorer/src/app/transactions/[hash]/page.tsx +++ b/explorer/src/app/transactions/[hash]/page.tsx @@ -1,30 +1,15 @@ 'use client'; -import { useEffect, useState, use } from 'react'; +import { useEffect, useState, useCallback, use } from 'react'; import Link from 'next/link'; -import { useRouter } from 'next/navigation'; import { Transaction } from '@/lib/types'; +import { useTransactionPolling } from '@/hooks/useTransactionPolling'; import { StatusBadge } from '@/components/StatusBadge'; import { TransactionTypeLabel } from '@/components/TransactionTypeLabel'; import { CopyButton } from '@/components/CopyButton'; import { Card, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, -} from '@/components/ui/dialog'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; import { OverviewTab, MonitoringTab, @@ -42,10 +27,8 @@ import { Hash, Cpu, Activity, - Trash2, - Edit2, } from 'lucide-react'; -import { TransactionStatus } from '@/lib/types'; +import { isTerminalStatus } from '@/lib/constants'; interface TransactionDetail { transaction: Transaction; @@ -63,14 +46,9 @@ const TABS = [ export default function TransactionDetailPage({ params }: { params: Promise<{ hash: string }> }) { const { hash } = use(params); - const router = useRouter(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); - const [deleting, setDeleting] = useState(false); - const [updatingStatus, setUpdatingStatus] = useState(false); - const [showStatusEdit, setShowStatusEdit] = useState(false); useEffect(() => { async function fetchTransaction() { @@ -91,61 +69,10 @@ export default function TransactionDetailPage({ params }: { params: Promise<{ ha fetchTransaction(); }, [hash]); - const handleDelete = async () => { - setDeleting(true); - try { - const res = await fetch(`/api/transactions/${hash}`, { - method: 'DELETE', - }); - - if (!res.ok) { - const errorData = await res.json(); - throw new Error(errorData.error || 'Failed to delete transaction'); - } - - router.push('/transactions'); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to delete transaction'); - setDeleting(false); - setShowDeleteConfirm(false); - } - }; - - const handleStatusUpdate = async (newStatus: TransactionStatus) => { - setUpdatingStatus(true); - setError(null); - try { - const res = await fetch(`/api/transactions/${hash}`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ status: newStatus }), - }); - - if (!res.ok) { - const errorData = await res.json(); - throw new Error(errorData.error || 'Failed to update transaction status'); - } - - const fetchRes = await fetch(`/api/transactions/${hash}`); - if (fetchRes.ok) { - const updatedData = await fetchRes.json(); - setData(updatedData); - } - setShowStatusEdit(false); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to update transaction status'); - } finally { - setUpdatingStatus(false); - } - }; - - const validStatuses: TransactionStatus[] = [ - 'PENDING', 'ACTIVATED', 'CANCELED', 'PROPOSING', 'COMMITTING', - 'REVEALING', 'ACCEPTED', 'FINALIZED', 'UNDETERMINED', - 'LEADER_TIMEOUT', 'VALIDATORS_TIMEOUT', - ]; + // Poll for updates when transaction is not in a terminal state + // Pauses polling when the browser tab is hidden + const stableSetData = useCallback((d: TransactionDetail) => setData(d), []); + useTransactionPolling(hash, data, stableSetData); if (loading) { return ( @@ -197,108 +124,22 @@ export default function TransactionDetailPage({ params }: { params: Promise<{ ha
-
- {showStatusEdit ? ( -
- - - {updatingStatus && } -
- ) : ( -
- - -
+ + {!isTerminalStatus(tx.status) && ( + + + + + + Live + )}

- {/* Delete Confirmation Dialog */} - - - - Delete Transaction - - Are you sure you want to delete this transaction? - - -

{tx.hash}

- {triggeredTransactions.length > 0 && ( - - -

- Note: This transaction has {triggeredTransactions.length} child transaction(s). - Their parent reference will be removed. -

-
-
- )} - - - - -
-
- {/* Alert Badges */} diff --git a/explorer/src/app/transactions/page.tsx b/explorer/src/app/transactions/page.tsx index 7354c695d..6d14be597 100644 --- a/explorer/src/app/transactions/page.tsx +++ b/explorer/src/app/transactions/page.tsx @@ -1,15 +1,17 @@ 'use client'; import { useEffect, useState, useCallback, Suspense } from 'react'; -import { useSearchParams, useRouter } from 'next/navigation'; +import { useSearchParams } from 'next/navigation'; +import { usePagination } from '@/hooks/usePagination'; import { Transaction } from '@/lib/types'; import { TransactionTable } from '@/components/TransactionTable'; -import { TRANSACTION_STATUS_OPTIONS, PAGE_SIZE_OPTIONS } from '@/lib/constants'; +import { PAGE_SIZE_OPTIONS, TRANSACTION_TABS } from '@/lib/constants'; import { Card, CardContent } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { Search, ChevronLeft, ChevronRight, Loader2, Filter, X } from 'lucide-react'; +import { Search, ChevronLeft, ChevronRight, Loader2, X } from 'lucide-react'; import { DateTimePicker } from '@/components/DateTimePicker'; interface TransactionsResponse { @@ -23,21 +25,23 @@ interface TransactionsResponse { } function TransactionsContent() { - const router = useRouter(); const searchParams = useSearchParams(); + const { page, limit, updateParams } = usePagination(searchParams, 20); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [searchQuery, setSearchQuery] = useState(searchParams.get('search') || ''); const [highlightedHashes, setHighlightedHashes] = useState>(new Set()); - const page = parseInt(searchParams.get('page') || '1'); - const limit = parseInt(searchParams.get('limit') || '20'); - const status = searchParams.get('status') || ''; + const tab = searchParams.get('tab') || 'all'; const search = searchParams.get('search') || ''; const fromDate = searchParams.get('from_date') || ''; const toDate = searchParams.get('to_date') || ''; + // Derive comma-separated statuses from the active tab + const activeTab = TRANSACTION_TABS.find(t => t.id === tab) || TRANSACTION_TABS[0]; + const statusFilter = activeTab.statuses ? activeTab.statuses.join(',') : ''; + const fetchTransactions = useCallback(async () => { setLoading(true); setError(null); @@ -45,7 +49,7 @@ function TransactionsContent() { const params = new URLSearchParams(); params.set('page', page.toString()); params.set('limit', limit.toString()); - if (status) params.set('status', status); + if (statusFilter) params.set('status', statusFilter); if (search) params.set('search', search); if (fromDate) params.set('from_date', fromDate); if (toDate) params.set('to_date', toDate); @@ -59,24 +63,12 @@ function TransactionsContent() { } finally { setLoading(false); } - }, [page, limit, status, search, fromDate, toDate]); + }, [page, limit, statusFilter, search, fromDate, toDate]); useEffect(() => { fetchTransactions(); }, [fetchTransactions]); - const updateParams = (updates: Record) => { - const params = new URLSearchParams(searchParams.toString()); - Object.entries(updates).forEach(([key, value]) => { - if (value === null || value === '') { - params.delete(key); - } else { - params.set(key, value); - } - }); - router.push(`/transactions?${params.toString()}`); - }; - const handleSearch = (e: React.FormEvent) => { e.preventDefault(); updateParams({ search: searchQuery, page: '1' }); @@ -108,6 +100,14 @@ function TransactionsContent() {

Browse and search all transactions

+ updateParams({ tab: value === 'all' ? null : value, page: '1' })}> + + {TRANSACTION_TABS.map((t) => ( + {t.label} + ))} + + +
@@ -124,24 +124,6 @@ function TransactionsContent() {
-
- - -
-
- {(status || search || fromDate || toDate) && ( + {(tab !== 'all' || search || fromDate || toDate) && ( + ) : ( + + {circleContent} + ); return ( diff --git a/explorer/src/components/ContractInteraction.tsx b/explorer/src/components/ContractInteraction.tsx new file mode 100644 index 000000000..8ef5c0833 --- /dev/null +++ b/explorer/src/components/ContractInteraction.tsx @@ -0,0 +1,158 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { CodeBlock } from '@/components/CodeBlock'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent } from '@/components/ui/card'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; +import { Loader2, AlertCircle, Eye, Pencil, FileCode, Info } from 'lucide-react'; +import { MethodForm } from '@/components/MethodForm'; +import { + fetchContractSchema, + type ContractSchema, +} from '@/lib/contractSchema'; + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +interface ContractInteractionProps { + address: string; + code: string | null; +} + +export function ContractInteraction({ address, code }: ContractInteractionProps) { + const [schema, setSchema] = useState(null); + const [schemaLoading, setSchemaLoading] = useState(false); + const [schemaError, setSchemaError] = useState(null); + + useEffect(() => { + setSchemaLoading(true); + setSchemaError(null); + fetchContractSchema(address) + .then(setSchema) + .catch((e) => setSchemaError(e.message)) + .finally(() => setSchemaLoading(false)); + }, [address]); + + const readMethods = schema + ? Object.entries(schema.methods).filter(([, m]) => m.readonly) + : []; + const writeMethods = schema + ? Object.entries(schema.methods).filter(([, m]) => !m.readonly) + : []; + + return ( + + + + + Code + + + + Read Contract + {readMethods.length > 0 && ( + {readMethods.length} + )} + + + + Write Contract + {writeMethods.length > 0 && ( + {writeMethods.length} + )} + + + + + + + {code ? :

No source code available

} +
+
+
+ + + + + {schemaLoading && ( +
+ + Loading contract schema... +
+ )} + {schemaError && ( +
+ + {schemaError} +
+ )} + {schema && readMethods.length === 0 && ( +

No read methods found

+ )} + {readMethods.length > 0 && ( +
+ {readMethods.map(([name, method], i) => ( + + ))} +
+ )} +
+
+
+ + + + +
+ +
+

Write methods require a connected wallet

+

+ To execute write transactions, use GenLayer Studio where you can sign and submit transactions. +

+
+
+ {schemaLoading && ( +
+ + Loading contract schema... +
+ )} + {schemaError && ( +
+ + {schemaError} +
+ )} + {schema && writeMethods.length === 0 && ( +

No write methods found

+ )} + {writeMethods.length > 0 && ( +
+ {writeMethods.map(([name, method], i) => ( + + ))} +
+ )} +
+
+
+
+ ); +} diff --git a/explorer/src/components/DataDecodePanel.tsx b/explorer/src/components/DataDecodePanel.tsx index ec57738bc..bf7c71aa0 100644 --- a/explorer/src/components/DataDecodePanel.tsx +++ b/explorer/src/components/DataDecodePanel.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState, useMemo } from 'react'; -import { b64ToArray } from '@/lib/resultDecoder'; +import { b64ToArray, decodeCalldata } from '@/lib/resultDecoder'; import { JsonViewer } from '@/components/JsonViewer'; import { CodeBlock } from '@/components/CodeBlock'; import { Button } from '@/components/ui/button'; @@ -11,25 +11,33 @@ interface DataDecodePanelProps { data: Record; } +type DecodedField = + | { kind: 'calldata'; methodName: string; args: unknown[] } + | { kind: 'text'; decoded: string; isCode: boolean }; + function looksLikeBase64(value: unknown): boolean { if (typeof value !== 'string') return false; if (value.length < 24) return false; - // Strict base64 alphabet only (standard or URL-safe, not mixed with plain text) return /^[A-Za-z0-9+/=]+$/.test(value) || /^[A-Za-z0-9\-_=]+$/.test(value); } -function tryDecodeField(value: unknown): { decoded: string; isCode: boolean } | null { +function tryDecodeField(key: string, value: unknown): DecodedField | null { if (!looksLikeBase64(value)) return null; + // Use calldata-aware decoder for the `calldata` field + if (key === 'calldata' && typeof value === 'string') { + const result = decodeCalldata(value); + if (result && result.methodName) return { kind: 'calldata', methodName: result.methodName, args: result.args }; + } + const bytes = b64ToArray(value); if (bytes.length === 0) return null; try { const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); - // Check if it's printable text (not binary garbage) if (/[\x00-\x08\x0E-\x1F]/.test(text)) return null; const isCode = text.includes('class ') || text.includes('def ') || text.includes('import '); - return { decoded: text, isCode }; + return { kind: 'text', decoded: text, isCode }; } catch { return null; } @@ -39,9 +47,9 @@ export function DataDecodePanel({ data }: DataDecodePanelProps) { const [mode, setMode] = useState<'decoded' | 'raw'>('decoded'); const decodedFields = useMemo(() => { - const fields: Record = {}; + const fields: Record = {}; for (const [key, value] of Object.entries(data)) { - const result = tryDecodeField(value); + const result = tryDecodeField(key, value); if (result) fields[key] = result; } return fields; @@ -87,7 +95,22 @@ export function DataDecodePanel({ data }: DataDecodePanelProps) {
{key}
{decoded ? ( - decoded.isCode ? ( + decoded.kind === 'calldata' ? ( +
+
+ Method + {decoded.methodName} +
+ {decoded.args.length > 0 && ( +
+ Parameters +
+ +
+
+ )} +
+ ) : decoded.isCode ? ( ) : (
diff --git a/explorer/src/components/DateTimePicker.tsx b/explorer/src/components/DateTimePicker.tsx
index e39db2dc7..0318ff5be 100644
--- a/explorer/src/components/DateTimePicker.tsx
+++ b/explorer/src/components/DateTimePicker.tsx
@@ -17,8 +17,9 @@ interface DateTimePickerProps {
 export function DateTimePicker({ value, onChange, placeholder = 'Pick date & time' }: DateTimePickerProps) {
   const [open, setOpen] = useState(false);
 
-  // Parse current value
-  const date = value ? new Date(value) : undefined;
+  // Parse current value (guard against invalid date strings)
+  const parsedDate = value ? new Date(value) : undefined;
+  const date = parsedDate && !Number.isNaN(parsedDate.getTime()) ? parsedDate : undefined;
   const hours = date ? date.getHours().toString().padStart(2, '0') : '00';
   const minutes = date ? date.getMinutes().toString().padStart(2, '0') : '00';
 
diff --git a/explorer/src/components/GlobalSearch.tsx b/explorer/src/components/GlobalSearch.tsx
index 3bf8995dc..17ec204b7 100644
--- a/explorer/src/components/GlobalSearch.tsx
+++ b/explorer/src/components/GlobalSearch.tsx
@@ -2,23 +2,29 @@
 
 import { useState, useEffect, useRef, useCallback } from 'react';
 import { useRouter } from 'next/navigation';
-import { Search, ArrowRightLeft, Database, Loader2 } from 'lucide-react';
+import { Search, ArrowRightLeft, Database, Loader2, Clock, Users } from 'lucide-react';
 import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
 import { StatusBadge } from '@/components/StatusBadge';
 import { Badge } from '@/components/ui/badge';
 import { truncateHash, truncateAddress } from '@/lib/formatters';
-import type { Transaction, CurrentState, TransactionStatus } from '@/lib/types';
+import type { Transaction, CurrentState, Validator, TransactionStatus } from '@/lib/types';
 
 interface SearchResults {
   transactions: Transaction[];
   states: CurrentState[];
+  validators: Validator[];
 }
 
 export function GlobalSearch() {
   const [open, setOpen] = useState(false);
   const [query, setQuery] = useState('');
-  const [results, setResults] = useState({ transactions: [], states: [] });
+  const [results, setResults] = useState({ transactions: [], states: [], validators: [] });
   const [loading, setLoading] = useState(false);
+  const [isMac, setIsMac] = useState(false);
+
+  useEffect(() => {
+    setIsMac(/Mac|iPhone|iPad/.test(navigator.userAgent));
+  }, []);
   const router = useRouter();
   const debounceRef = useRef | null>(null);
   const abortRef = useRef(null);
@@ -42,7 +48,7 @@ export function GlobalSearch() {
       setTimeout(() => inputRef.current?.focus(), 0);
     } else {
       setQuery('');
-      setResults({ transactions: [], states: [] });
+      setResults({ transactions: [], states: [], validators: [] });
     }
   }, [open]);
 
@@ -54,7 +60,7 @@ export function GlobalSearch() {
 
   const search = useCallback(async (q: string) => {
     if (!q.trim()) {
-      setResults({ transactions: [], states: [] });
+      setResults({ transactions: [], states: [], validators: [] });
       setLoading(false);
       return;
     }
@@ -67,21 +73,24 @@ export function GlobalSearch() {
     setLoading(true);
     try {
       const encoded = encodeURIComponent(q.trim());
-      const [txRes, stateRes] = await Promise.all([
+      const [txRes, stateRes, valRes] = await Promise.all([
         fetch(`/api/transactions?search=${encoded}&limit=5`, { signal: controller.signal }),
-        fetch(`/api/state?search=${encoded}&limit=5`, { signal: controller.signal }),
+        fetch(`/api/contracts?search=${encoded}&limit=5`, { signal: controller.signal }),
+        fetch(`/api/validators?search=${encoded}&limit=5`, { signal: controller.signal }),
       ]);
 
       const txData = txRes.ok ? await txRes.json() : { transactions: [] };
       const stateData = stateRes.ok ? await stateRes.json() : { states: [] };
+      const valData = valRes.ok ? await valRes.json() : { validators: [] };
 
       setResults({
         transactions: txData.transactions || [],
         states: stateData.states || [],
+        validators: valData.validators || [],
       });
     } catch (err) {
       if (err instanceof DOMException && err.name === 'AbortError') return;
-      setResults({ transactions: [], states: [] });
+      setResults({ transactions: [], states: [], validators: [] });
     } finally {
       setLoading(false);
     }
@@ -98,7 +107,7 @@ export function GlobalSearch() {
     router.push(path);
   };
 
-  const hasResults = results.transactions.length > 0 || results.states.length > 0;
+  const hasResults = results.transactions.length > 0 || results.states.length > 0 || results.validators.length > 0;
 
   return (
     <>
@@ -109,7 +118,7 @@ export function GlobalSearch() {
         
         Search...
         
-          {typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.userAgent) ? '⌘K' : 'Ctrl+K'}
+          {isMac ? '⌘K' : 'Ctrl+K'}
         
       
 
@@ -129,6 +138,29 @@ export function GlobalSearch() {
             {loading && }
           
+ {!query.trim() && ( +
+
+ Quick Links +
+ {[ + { icon: ArrowRightLeft, label: 'All Transactions', href: '/transactions' }, + { icon: Clock, label: 'In Progress Transactions', href: '/transactions?tab=in_progress' }, + { icon: Database, label: 'All Contracts', href: '/contracts' }, + { icon: Users, label: 'All Validators', href: '/validators' }, + ].map((link) => ( + + ))} +
+ )} + {query.trim() && (
{!loading && !hasResults && ( @@ -165,7 +197,7 @@ export function GlobalSearch() { {results.states.map((state) => (
)} + + {results.validators.length > 0 && ( +
0 || results.states.length > 0) ? 'mt-2' : ''}> +
+ + Validators +
+ {results.validators.map((v) => ( + + ))} +
+ )}
)} diff --git a/explorer/src/components/InputDataPanel.tsx b/explorer/src/components/InputDataPanel.tsx new file mode 100644 index 000000000..1c588c296 --- /dev/null +++ b/explorer/src/components/InputDataPanel.tsx @@ -0,0 +1,153 @@ +'use client'; + +import { useState, useMemo } from 'react'; +import { Download, Copy, Check } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { b64ToArray, decodeCalldata } from '@/lib/resultDecoder'; + +interface InputDataPanelProps { + calldataB64: string; +} + +type ViewMode = 'decoded' | 'hex' | 'raw'; + +function toHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') + .match(/.{1,2}/g) + ?.join(' ') ?? ''; +} + +function serializeArg(val: unknown): unknown { + if (typeof val === 'bigint') return val.toString(); + if (Array.isArray(val)) return val.map(serializeArg); + if (val instanceof Map) { + const obj: Record = {}; + val.forEach((v, k) => { obj[String(k)] = serializeArg(v); }); + return obj; + } + return val; +} + +export function InputDataPanel({ calldataB64 }: InputDataPanelProps) { + const [mode, setMode] = useState('decoded'); + const [copied, setCopied] = useState(false); + + const bytes = useMemo(() => b64ToArray(calldataB64), [calldataB64]); + const decoded = useMemo(() => decodeCalldata(calldataB64), [calldataB64]); + const hex = useMemo(() => toHex(bytes), [bytes]); + + const handleCopy = () => { + let text = ''; + if (mode === 'hex') text = hex; + else if (mode === 'raw') text = calldataB64; + else if (decoded) text = JSON.stringify({ method: decoded.methodName, args: decoded.args.map(serializeArg) }, null, 2); + navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }; + + const handleExportJson = () => { + if (!decoded) return; + const payload = { method: decoded.methodName, args: decoded.args.map(serializeArg) }; + const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'input-data.json'; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( +
+ {/* Toolbar */} +
+
+ {(['decoded', 'hex', 'raw'] as ViewMode[]).map((v) => ( + + ))} +
+
+ {decoded && ( + + )} + +
+
+ + {/* Content */} +
+ {mode === 'decoded' && decoded ? ( +
+ {/* Method — only for call transactions */} + {decoded.methodName && ( +
+
Method
+ {decoded.methodName} +
+ )} + + {/* Parameters / Constructor Arguments */} + {decoded.args.length > 0 && ( +
+
+ {decoded.methodName ? 'Parameters' : 'Constructor Arguments'} +
+
+ + + + + + + + + {decoded.args.map((arg, i) => ( + + + + + ))} + +
#Value
{i} + {typeof arg === 'bigint' + ? arg.toString() + : typeof arg === 'string' + ? arg + : JSON.stringify(serializeArg(arg))} +
+
+
+ )} +
+ ) : mode === 'decoded' && !decoded ? ( +

Unable to decode calldata.

+ ) : mode === 'hex' ? ( +
+            {hex || '(empty)'}
+          
+ ) : ( +
+            {calldataB64}
+          
+ )} +
+
+ ); +} diff --git a/explorer/src/components/MethodForm.tsx b/explorer/src/components/MethodForm.tsx new file mode 100644 index 000000000..2fd15c462 --- /dev/null +++ b/explorer/src/components/MethodForm.tsx @@ -0,0 +1,167 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { + Collapsible, + CollapsibleTrigger, + CollapsibleContent, +} from '@/components/ui/collapsible'; +import { ChevronRight, Play, Loader2 } from 'lucide-react'; +import { + callReadMethod, + parseParamValue, + type ContractMethod, +} from '@/lib/contractSchema'; + +// --------------------------------------------------------------------------- +// MethodForm — a single collapsible method with inputs and (optional) execute +// --------------------------------------------------------------------------- + +export function MethodForm({ + index, + name, + method, + address, + executable, +}: { + index: number; + name: string; + method: ContractMethod; + address: string; + executable: boolean; +}) { + const [open, setOpen] = useState(false); + const [inputs, setInputs] = useState>(() => { + const init: Record = {}; + for (const [pName] of method.params) init[pName] = ''; + return init; + }); + const [result, setResult] = useState(undefined); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const handleExecute = useCallback(async () => { + setLoading(true); + setError(null); + setResult(undefined); + try { + const args = method.params.map(([pName, pType]) => + parseParamValue(inputs[pName] || '', pType) + ); + const res = await callReadMethod(address, name, args); + setResult(res); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + }, [address, name, method.params, inputs]); + + const hasParams = method.params.length > 0; + + return ( + + + + {index}. + {name} + {!hasParams && executable && ( + no args + )} + {method.payable && ( + + payable + + )} + + +
+ {/* Parameter inputs */} + {method.params.map(([pName, pType]) => ( +
+ + {executable ? ( + setInputs((prev) => ({ ...prev, [pName]: e.target.value }))} + placeholder={pType === 'bool' ? 'true / false' : pType} + className="font-mono text-sm h-8" + /> + ) : ( +
+ {pType} +
+ )} +
+ ))} + + {/* Return type */} + {method.ret && ( +
+ Returns: {method.ret} +
+ )} + + {/* Execute button (read only) */} + {executable && ( + + )} + + {/* Result */} + {result !== undefined && ( +
+
Result
+ + {formatResult(result)} + +
+ )} + + {/* Error */} + {error && ( +
+
Error
+ {error} +
+ )} +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +export function formatResult(val: unknown): string { + if (val === null || val === undefined) return 'null'; + if (typeof val === 'bigint') return val.toString(); + if (val instanceof Map) { + const obj: Record = {}; + val.forEach((v, k) => { obj[String(k)] = v; }); + return JSON.stringify(obj, replacer, 2); + } + if (typeof val === 'object') { + return JSON.stringify(val, replacer, 2); + } + return String(val); +} + +export function replacer(_key: string, value: unknown): unknown { + if (typeof value === 'bigint') return value.toString(); + return value; +} diff --git a/explorer/src/components/Navigation.tsx b/explorer/src/components/Navigation.tsx index 5643708e5..c3ee05451 100644 --- a/explorer/src/components/Navigation.tsx +++ b/explorer/src/components/Navigation.tsx @@ -1,8 +1,9 @@ 'use client'; import Link from 'next/link'; +import Image from 'next/image'; import { usePathname } from 'next/navigation'; -import { LayoutDashboard, ArrowRightLeft, Users, Database, Cpu, Layers } from 'lucide-react'; +import { LayoutDashboard, ArrowRightLeft, Users, Database, Cpu } from 'lucide-react'; import { GlobalSearch } from './GlobalSearch'; import { ThemeToggle } from './ThemeToggle'; import { cn } from '@/lib/utils'; @@ -24,10 +25,8 @@ export function Navigation() {
{/* Logo */} -
- -
- Studio Explorer + GenLayer Logo + GenLayer Studio Explorer {/* Nav Links */} diff --git a/explorer/src/components/SparklineChart.tsx b/explorer/src/components/SparklineChart.tsx new file mode 100644 index 000000000..ef970eb30 --- /dev/null +++ b/explorer/src/components/SparklineChart.tsx @@ -0,0 +1,48 @@ +'use client'; + +interface SparklineChartProps { + data: number[]; + width?: number; + height?: number; + className?: string; +} + +export function SparklineChart({ + data, + width = 200, + height = 50, + className, +}: SparklineChartProps) { + if (data.length === 0) return null; + + const max = Math.max(...data, 1); + const min = Math.min(...data, 0); + const range = max - min || 1; + + const padding = 2; + const chartWidth = width - padding * 2; + const chartHeight = height - padding * 2; + + const points = data.map((value, i) => { + const x = padding + (i / Math.max(data.length - 1, 1)) * chartWidth; + const y = padding + chartHeight - ((value - min) / range) * chartHeight; + return { x, y }; + }); + + const linePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' '); + + const areaPath = `${linePath} L${points[points.length - 1].x},${height - padding} L${points[0].x},${height - padding} Z`; + + return ( + + ); +} diff --git a/explorer/src/components/StatCard.tsx b/explorer/src/components/StatCard.tsx index 46db0d646..872d26ee9 100644 --- a/explorer/src/components/StatCard.tsx +++ b/explorer/src/components/StatCard.tsx @@ -1,14 +1,31 @@ 'use client'; import Link from 'next/link'; -import { ChevronRight } from 'lucide-react'; +import { + ArrowRightLeft, + Users, + Database, + AlertTriangle, + Zap, + ChevronRight, +} from 'lucide-react'; import { Card, CardContent } from '@/components/ui/card'; import { cn } from '@/lib/utils'; +const ICON_MAP = { + ArrowRightLeft, + Users, + Database, + AlertTriangle, + Zap, +} as const; + +export type StatCardIcon = keyof typeof ICON_MAP; + interface StatCardProps { title: string; value: number | string; - icon: React.ComponentType<{ className?: string }>; + icon: StatCardIcon; color: string; iconBg: string; href?: string; @@ -17,11 +34,12 @@ interface StatCardProps { export function StatCard({ title, value, - icon: Icon, + icon, color, iconBg, href, }: StatCardProps) { + const Icon = ICON_MAP[icon]; const content = ( diff --git a/explorer/src/components/StatItem.tsx b/explorer/src/components/StatItem.tsx new file mode 100644 index 000000000..9e64c08e5 --- /dev/null +++ b/explorer/src/components/StatItem.tsx @@ -0,0 +1,22 @@ +import React from 'react'; + +export function StatItem({ icon, iconBg, label, value, small }: { + icon: React.ReactNode; + iconBg: string; + label: string; + value: string; + small?: boolean; +}) { + return ( +
+
{icon}
+
+

{label}

+ {small + ?

{value}

+ :

{value}

+ } +
+
+ ); +} diff --git a/explorer/src/components/StatsBar.tsx b/explorer/src/components/StatsBar.tsx index e8423cfdb..ba9a51fa1 100644 --- a/explorer/src/components/StatsBar.tsx +++ b/explorer/src/components/StatsBar.tsx @@ -1,7 +1,5 @@ -'use client'; - -import { useEffect, useState } from 'react'; import { Separator } from '@/components/ui/separator'; +import { fetchBackend } from '@/lib/fetchBackend'; interface StatsBarData { totalTransactions: number; @@ -9,26 +7,15 @@ interface StatsBarData { totalContracts: number; } -export function StatsBar() { - const [stats, setStats] = useState(null); +export async function StatsBar() { + let stats: StatsBarData | null = null; - useEffect(() => { - async function fetchStats() { - try { - const res = await fetch('/api/stats/counts'); - if (!res.ok) return; - const data = await res.json(); - setStats({ - totalTransactions: data.totalTransactions, - totalValidators: data.totalValidators, - totalContracts: data.totalContracts, - }); - } catch { - // Silently fail — stats bar is non-critical - } - } - fetchStats(); - }, []); + try { + stats = await fetchBackend('/stats/counts'); + } catch { + // Stats bar is non-critical — render nothing on failure + return null; + } if (!stats) return null; diff --git a/explorer/src/components/TransactionTable.tsx b/explorer/src/components/TransactionTable.tsx index 9458173d6..234cb8d2d 100644 --- a/explorer/src/components/TransactionTable.tsx +++ b/explorer/src/components/TransactionTable.tsx @@ -1,68 +1,40 @@ 'use client'; -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useRef } from 'react'; import Link from 'next/link'; import { formatDistanceToNow } from 'date-fns'; import { ArrowUpRight, ArrowDownRight, Settings2 } from 'lucide-react'; import { StatusBadge } from '@/components/StatusBadge'; -import { CopyButton } from '@/components/CopyButton'; +import { AddressDisplay } from '@/components/AddressDisplay'; import { TransactionTypeLabel } from '@/components/TransactionTypeLabel'; import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table'; import { Button } from '@/components/ui/button'; import { Transaction } from '@/lib/types'; import { getTimeToAccepted, getTimeToFinalized, getExecutionResult } from '@/lib/transactionUtils'; -import { truncateHash, truncateAddress } from '@/lib/formatters'; +import { decodeCalldata } from '@/lib/resultDecoder'; import { cn } from '@/lib/utils'; +import { useColumnVisibility } from '@/hooks/useColumnVisibility'; +import type { ColumnDef } from '@/hooks/useColumnVisibility'; const STORAGE_KEY = 'explorer:tx-table-columns'; -interface ColumnDef { - id: string; - label: string; - defaultVisible: boolean; - alwaysVisible?: boolean; -} - const OPTIONAL_COLUMNS: ColumnDef[] = [ { id: 'hash', label: 'Hash', defaultVisible: true, alwaysVisible: true }, - { id: 'relations', label: 'Relations', defaultVisible: true }, { id: 'type', label: 'Type', defaultVisible: true }, { id: 'status', label: 'Status', defaultVisible: true }, { id: 'from', label: 'From', defaultVisible: true }, { id: 'to', label: 'To', defaultVisible: true }, + { id: 'method', label: 'Method', defaultVisible: true }, { id: 'genvmResult', label: 'GenVM Result', defaultVisible: true }, { id: 'time', label: 'Time', defaultVisible: true }, - { id: 'accepted', label: 'Accepted', defaultVisible: true }, - { id: 'finalized', label: 'Finalized', defaultVisible: true }, + { id: 'relations', label: 'Relations', defaultVisible: true }, + { id: 'accepted', label: 'Accepted', defaultVisible: false }, + { id: 'finalized', label: 'Finalized', defaultVisible: false }, { id: 'blockedAt', label: 'Blocked At', defaultVisible: false }, { id: 'worker', label: 'Worker', defaultVisible: false }, ]; -function getDefaultVisibility(): Record { - const defaults: Record = {}; - for (const col of OPTIONAL_COLUMNS) { - defaults[col.id] = col.defaultVisible; - } - return defaults; -} - -function loadColumnVisibility(): Record { - if (typeof window === 'undefined') return getDefaultVisibility(); - try { - const stored = localStorage.getItem(STORAGE_KEY); - if (stored) { - const parsed = JSON.parse(stored); - // Merge with defaults so new columns get their default value - const defaults = getDefaultVisibility(); - return { ...defaults, ...parsed }; - } - } catch { - // ignore - } - return getDefaultVisibility(); -} - interface TransactionTableProps { transactions: Transaction[]; showRelations?: boolean; @@ -81,25 +53,28 @@ export function TransactionTable({ highlightedHashes = new Set(), }: TransactionTableProps) { const [highlightedAddress, setHighlightedAddress] = useState(null); - const [columnVisibility, setColumnVisibility] = useState>(getDefaultVisibility); - const [showColumnPicker, setShowColumnPicker] = useState(false); + const { + isVisible: isColumnVisible, + showColumnPicker, + setShowColumnPicker, + toggleColumn, + } = useColumnVisibility(STORAGE_KEY, OPTIONAL_COLUMNS); + const columnPickerRef = useRef(null); - // Load from localStorage on mount useEffect(() => { - setColumnVisibility(loadColumnVisibility()); - }, []); - - const toggleColumn = useCallback((columnId: string) => { - setColumnVisibility(prev => { - const next = { ...prev, [columnId]: !prev[columnId] }; - localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); - return next; - }); - }, []); + if (!showColumnPicker) return; + const handler = (e: MouseEvent) => { + if (columnPickerRef.current && !columnPickerRef.current.contains(e.target as Node)) { + setShowColumnPicker(false); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [showColumnPicker, setShowColumnPicker]); const isVisible = (columnId: string) => { if (columnId === 'relations' && !showRelations) return false; - return columnVisibility[columnId] !== false; + return isColumnVisible(columnId); }; const visibleCount = OPTIONAL_COLUMNS.filter(c => @@ -121,9 +96,7 @@ export function TransactionTable({ Columns {showColumnPicker && ( - <> -
setShowColumnPicker(false)} /> -
+
{OPTIONAL_COLUMNS.filter(c => c.id === 'relations' ? showRelations : true).map(col => (
- )}
@@ -152,13 +124,14 @@ export function TransactionTable({ {isVisible('hash') && Hash} - {isVisible('relations') && Relations} {isVisible('type') && Type} {isVisible('status') && Status} {isVisible('from') && From} {isVisible('to') && To} + {isVisible('method') && Method} {isVisible('genvmResult') && GenVM Result} {isVisible('time') && Time} + {isVisible('relations') && Relations} {isVisible('accepted') && Accepted} {isVisible('finalized') && Finalized} {isVisible('blockedAt') && Blocked At} @@ -178,6 +151,11 @@ export function TransactionTable({ const executionResult = execResult?.executionResult; const timeToAccepted = getTimeToAccepted(tx); const timeToFinalized = getTimeToFinalized(tx); + const calldataB64 = (tx.type === 1 || tx.type === 2) && tx.data && typeof tx.data === 'object' + ? (tx.data as Record).calldata as string | undefined + : undefined; + const decodedInput = calldataB64 ? decodeCalldata(calldataB64) : null; + const methodName = decodedInput?.methodName ?? (decodedInput && !decodedInput.methodName ? '(constructor)' : undefined); return ( {isVisible('hash') && ( -
- - {truncateHash(tx.hash)} - - -
-
- )} - {isVisible('relations') && ( - -
- {tx.triggered_by_hash && ( - onHighlightParent?.(tx.triggered_by_hash)} - onMouseLeave={onClearHighlights} - > - - Parent - - )} - {tx.triggered_count !== undefined && tx.triggered_count > 0 && ( - onHighlightChildren?.(tx.hash)} - onMouseLeave={onClearHighlights} - > - - {tx.triggered_count} - - )} - {!tx.triggered_by_hash && (!tx.triggered_count || tx.triggered_count === 0) && ( - - - )} -
+
)} {isVisible('type') && ( @@ -245,19 +187,13 @@ export function TransactionTable({ {isVisible('from') && ( {tx.from_address ? ( -
setHighlightedAddress(tx.from_address)} onMouseLeave={() => setHighlightedAddress(null)} - > - - {truncateAddress(tx.from_address)} - - -
+ /> ) : ( - )} @@ -266,24 +202,25 @@ export function TransactionTable({ {isVisible('to') && ( {tx.to_address ? ( -
setHighlightedAddress(tx.to_address)} onMouseLeave={() => setHighlightedAddress(null)} - > - - {truncateAddress(tx.to_address)} - - -
+ /> ) : ( - )}
)} + {isVisible('method') && ( + + {methodName + ? {methodName} + : -} + + )} {isVisible('genvmResult') && ( {executionResult ? ( @@ -304,6 +241,39 @@ export function TransactionTable({ : -} )} + {isVisible('relations') && ( + +
+ {tx.triggered_by_hash && ( + onHighlightParent?.(tx.triggered_by_hash)} + onMouseLeave={onClearHighlights} + > + + Parent + + )} + {tx.triggered_count !== undefined && tx.triggered_count > 0 && ( + onHighlightChildren?.(tx.hash)} + onMouseLeave={onClearHighlights} + > + + {tx.triggered_count} + + )} + {!tx.triggered_by_hash && (!tx.triggered_count || tx.triggered_count === 0) && ( + - + )} +
+
+ )} {isVisible('accepted') && ( {timeToAccepted ? ( diff --git a/explorer/src/components/index.ts b/explorer/src/components/index.ts index 1aaf8acea..ab4297c3c 100644 --- a/explorer/src/components/index.ts +++ b/explorer/src/components/index.ts @@ -1,4 +1,5 @@ // Core UI Components +export { AddressDisplay } from './AddressDisplay'; export { CopyButton } from './CopyButton'; export { InfoRow } from './InfoRow'; export { JsonViewer } from './JsonViewer'; diff --git a/explorer/src/components/monitoring/AppealProcessing.tsx b/explorer/src/components/monitoring/AppealProcessing.tsx new file mode 100644 index 000000000..2426186e8 --- /dev/null +++ b/explorer/src/components/monitoring/AppealProcessing.tsx @@ -0,0 +1,37 @@ +import { Card, CardContent } from '@/components/ui/card'; +import { Timer } from 'lucide-react'; + +interface AppealProcessingProps { + appealProcessingTime: number; + appealFailed: number | null | undefined; +} + +export function AppealProcessing({ + appealProcessingTime, + appealFailed, +}: AppealProcessingProps) { + return ( +
+

+ + Appeal Processing +

+ + +
+
+

Processing Time

+

{appealProcessingTime}ms

+
+ {appealFailed !== null && appealFailed !== undefined && ( +
+

Failed Appeals

+

{appealFailed}

+
+ )} +
+
+
+
+ ); +} diff --git a/explorer/src/components/monitoring/LegacyFormatTimeline.tsx b/explorer/src/components/monitoring/LegacyFormatTimeline.tsx new file mode 100644 index 000000000..3b68b9504 --- /dev/null +++ b/explorer/src/components/monitoring/LegacyFormatTimeline.tsx @@ -0,0 +1,167 @@ +import { ConsensusHistoryEntry } from '@/lib/types'; +import { Card } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { + Activity, + CheckCircle, + XCircle, + Timer, + Check, + Crown, +} from 'lucide-react'; + +interface LegacyFormatTimelineProps { + consensusHistory: ConsensusHistoryEntry[]; +} + +export function LegacyFormatTimeline({ consensusHistory }: LegacyFormatTimelineProps) { + return ( +
+

+ + Consensus Timeline +

+
+
+ +
+ {consensusHistory.map((round, idx) => { + const votes = round.votes || []; + const agreeCount = votes.filter((v) => v.vote === 'agree').length; + const disagreeCount = votes.filter((v) => v.vote === 'disagree').length; + const timeoutCount = votes.filter((v) => v.vote === 'timeout').length; + const totalVotes = votes.length; + const leader = round.leader; + const isFinal = round.final; + + return ( +
+
+ {isFinal && } +
+ + +
+
+ + Round {(round.round as number) ?? idx + 1} + + {isFinal && ( + + Final + + )} +
+ {totalVotes > 0 && ( +
+ + + {agreeCount} + + + + {disagreeCount} + + + + {timeoutCount} + +
+ )} +
+ + {leader && ( +
+
+ + Leader +
+
+ {leader.address && ( +
+ Address: + + {String(leader.address).slice(0, 10)}...{String(leader.address).slice(-8)} + +
+ )} + {leader.mode && ( +
+ Mode: + {String(leader.mode)} +
+ )} +
+
+ )} + + {totalVotes > 0 && ( +
+
+ Vote Distribution + {totalVotes} votes +
+
+ {agreeCount > 0 && ( +
+ )} + {disagreeCount > 0 && ( +
+ )} + {timeoutCount > 0 && ( +
+ )} +
+
+ )} + + {votes.length > 0 && ( +
+
Validator Votes
+
+ {votes.map((vote, vIdx) => ( +
+ {vote.vote === 'agree' && } + {vote.vote === 'disagree' && } + {vote.vote === 'timeout' && } + + {vote.validator_address + ? `${vote.validator_address.slice(0, 6)}...${vote.validator_address.slice(-4)}` + : `Validator ${vIdx + 1}`} + +
+ ))} +
+
+ )} + +
+ ); + })} +
+
+
+ ); +} diff --git a/explorer/src/components/monitoring/MonitoringStats.tsx b/explorer/src/components/monitoring/MonitoringStats.tsx new file mode 100644 index 000000000..85d44805a --- /dev/null +++ b/explorer/src/components/monitoring/MonitoringStats.tsx @@ -0,0 +1,87 @@ +import { Card, CardContent } from '@/components/ui/card'; +import { Activity, Users, Timer, AlertTriangle, CheckCircle } from 'lucide-react'; +import { ConsensusHistoryData } from '@/lib/types'; +import { getConsensusRoundCount } from '@/lib/consensusUtils'; + +interface MonitoringStatsProps { + consensusHistory: ConsensusHistoryData | null; + numOfInitialValidators: number | null; + rotationCount: number | null; + appealed: boolean; +} + +export function MonitoringStats({ + consensusHistory, + numOfInitialValidators, + rotationCount, + appealed, +}: MonitoringStatsProps) { + return ( +
+ + +
+
+ +
+
+

Consensus Rounds

+

+ {getConsensusRoundCount(consensusHistory)} +

+
+
+
+
+ + +
+
+ +
+
+

Validators

+

+ {numOfInitialValidators || '-'} +

+
+
+
+
+ + +
+
+ +
+
+

Rotations

+

+ {rotationCount ?? 0} +

+
+
+
+
+ + +
+
+ {appealed ? ( + + ) : ( + + )} +
+
+

Appeal Status

+

+ {appealed ? 'Appealed' : 'None'} +

+
+
+
+
+
+ ); +} diff --git a/explorer/src/components/monitoring/NewFormatTimeline.tsx b/explorer/src/components/monitoring/NewFormatTimeline.tsx new file mode 100644 index 000000000..7e90b1da7 --- /dev/null +++ b/explorer/src/components/monitoring/NewFormatTimeline.tsx @@ -0,0 +1,136 @@ +import { NewConsensusHistory } from '@/lib/types'; +import { JsonViewer } from '@/components/JsonViewer'; +import { MonitoringTimeline } from '@/components/MonitoringTimeline'; +import { Card, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Activity, Check } from 'lucide-react'; + +interface NewFormatTimelineProps { + consensusHistory: NewConsensusHistory; +} + +export function NewFormatTimeline({ consensusHistory }: NewFormatTimelineProps) { + const firstRound = consensusHistory.consensus_results[0]; + const globalStartTime = firstRound?.monitoring?.PENDING; + + return ( +
+ {consensusHistory.current_status_changes.length > 0 && ( +
+

+ + Current Status +

+
+ {consensusHistory.current_status_changes.map((status, idx) => ( + + {status} + + ))} +
+
+ )} + + {Object.keys(consensusHistory.current_monitoring).length > 0 && ( + + + + + + )} + + {consensusHistory.consensus_results.length > 0 && ( +
+

+ + Consensus Rounds ({consensusHistory.consensus_results.length}) +

+
+
+ +
+ {consensusHistory.consensus_results.map((result, idx) => { + const isFinal = result.consensus_round === 'Accepted' || result.consensus_round === 'Finalized'; + + return ( +
+
+ {isFinal && } +
+ + +
+
+ + Round {idx + 1} + + + {result.consensus_round} + +
+
+ + {result.status_changes.length > 0 && ( +
+
Status Flow
+
+ {result.status_changes.map((status, sIdx) => ( +
+ + {status} + + {sIdx < result.status_changes.length - 1 && ( + + )} +
+ ))} +
+
+ )} + + {Object.keys(result.monitoring).length > 0 && ( +
+ +
+ )} + + {result.validator_results.length > 0 && ( +
+
Validator Results
+
+ +
+
+ )} +
+
+ ); + })} +
+
+
+ )} +
+ ); +} diff --git a/explorer/src/components/monitoring/TimingInformation.tsx b/explorer/src/components/monitoring/TimingInformation.tsx new file mode 100644 index 000000000..d8a105335 --- /dev/null +++ b/explorer/src/components/monitoring/TimingInformation.tsx @@ -0,0 +1,55 @@ +import { format } from 'date-fns'; +import { Card, CardContent } from '@/components/ui/card'; +import { Clock } from 'lucide-react'; + +interface TimingInformationProps { + lastVoteTimestamp: number | null | undefined; + timestampAppeal: number | null | undefined; + timestampAwaitingFinalization: number | null | undefined; +} + +function formatTs(ts: number | null | undefined): string { + if (ts === null || ts === undefined) return '-'; + const tsNum = Number(ts); + const date = new Date(tsNum < 1e12 ? tsNum * 1000 : tsNum); + return isNaN(date.getTime()) ? String(ts) : format(date, 'PPpp'); +} + +export function TimingInformation({ + lastVoteTimestamp, + timestampAppeal, + timestampAwaitingFinalization, +}: TimingInformationProps) { + return ( +
+

+ + Timing Information +

+ + +
+ {lastVoteTimestamp !== null && lastVoteTimestamp !== undefined && ( +
+

Last Vote

+

{formatTs(lastVoteTimestamp)}

+
+ )} + {timestampAppeal !== null && timestampAppeal !== undefined && ( +
+

Appeal Timestamp

+

{formatTs(timestampAppeal)}

+
+ )} + {timestampAwaitingFinalization !== null && timestampAwaitingFinalization !== undefined && ( +
+

Awaiting Finalization

+

{formatTs(timestampAwaitingFinalization)}

+
+ )} +
+
+
+
+ ); +} diff --git a/explorer/src/hooks/useColumnVisibility.ts b/explorer/src/hooks/useColumnVisibility.ts new file mode 100644 index 000000000..35fa865ff --- /dev/null +++ b/explorer/src/hooks/useColumnVisibility.ts @@ -0,0 +1,67 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; + +export interface ColumnDef { + id: string; + label: string; + defaultVisible: boolean; + alwaysVisible?: boolean; +} + +function getDefaultVisibility(columns: ColumnDef[]): Record { + const defaults: Record = {}; + for (const col of columns) { + defaults[col.id] = col.defaultVisible; + } + return defaults; +} + +function loadColumnVisibility(storageKey: string, columns: ColumnDef[]): Record { + if (typeof window === 'undefined') return getDefaultVisibility(columns); + try { + const stored = localStorage.getItem(storageKey); + if (stored) { + const parsed = JSON.parse(stored); + // Merge with defaults so new columns get their default value + const defaults = getDefaultVisibility(columns); + return { ...defaults, ...parsed }; + } + } catch { + // ignore + } + return getDefaultVisibility(columns); +} + +export function useColumnVisibility(storageKey: string, columns: ColumnDef[]) { + const [columnVisibility, setColumnVisibility] = useState>( + () => getDefaultVisibility(columns) + ); + const [showColumnPicker, setShowColumnPicker] = useState(false); + + // Load from localStorage on mount + useEffect(() => { + setColumnVisibility(loadColumnVisibility(storageKey, columns)); + }, [storageKey, columns]); + + const toggleColumn = useCallback((columnId: string) => { + setColumnVisibility(prev => { + const next = { ...prev, [columnId]: !prev[columnId] }; + localStorage.setItem(storageKey, JSON.stringify(next)); + return next; + }); + }, [storageKey]); + + const isVisible = (columnId: string) => { + return columnVisibility[columnId] !== false; + }; + + return { + columnVisibility, + setColumnVisibility, + isVisible, + showColumnPicker, + setShowColumnPicker, + toggleColumn, + }; +} diff --git a/explorer/src/hooks/usePagination.ts b/explorer/src/hooks/usePagination.ts new file mode 100644 index 000000000..64837ce69 --- /dev/null +++ b/explorer/src/hooks/usePagination.ts @@ -0,0 +1,25 @@ +'use client'; + +import { useSearchParams, useRouter } from 'next/navigation'; +import { useCallback } from 'react'; + +export function usePagination(searchParams: ReturnType, defaultLimit: number = 20) { + const router = useRouter(); + + const page = parseInt(searchParams.get('page') || '1', 10) || 1; + const limit = parseInt(searchParams.get('limit') || String(defaultLimit), 10) || defaultLimit; + + const updateParams = useCallback((updates: Record) => { + const params = new URLSearchParams(searchParams.toString()); + Object.entries(updates).forEach(([key, value]) => { + if (value === null || value === '') { + params.delete(key); + } else { + params.set(key, value); + } + }); + router.push(`?${params.toString()}`); + }, [searchParams, router]); + + return { page, limit, updateParams }; +} diff --git a/explorer/src/hooks/useTransactionPolling.ts b/explorer/src/hooks/useTransactionPolling.ts new file mode 100644 index 000000000..f659eac70 --- /dev/null +++ b/explorer/src/hooks/useTransactionPolling.ts @@ -0,0 +1,59 @@ +'use client'; + +import { useEffect } from 'react'; +import { isTerminalStatus } from '@/lib/constants'; +import type { TransactionStatus } from '@/lib/types'; + +interface HasTransactionStatus { + transaction: { status: TransactionStatus }; +} + +export function useTransactionPolling( + hash: string, + data: T | null, + setData: (d: T) => void, +) { + useEffect(() => { + if (!data || isTerminalStatus(data.transaction.status)) return; + + let timer: ReturnType | null = null; + let active = true; + + function schedulePoll() { + if (!active) return; + timer = setTimeout(async () => { + if (document.hidden || !active) return; + try { + const res = await fetch(`/api/transactions/${hash}`); + if (res.ok) { + const updated = await res.json(); + if (active) setData(updated); + // Effect re-runs on setData, so don't self-schedule on success + return; + } + } catch { + // silently ignore polling errors + } + // Reschedule on error/non-ok so polling doesn't die + schedulePoll(); + }, 5000); + } + + function onVisibilityChange() { + if (!document.hidden) { + // Tab became visible — resume polling immediately + if (timer) clearTimeout(timer); + schedulePoll(); + } + } + + schedulePoll(); + document.addEventListener('visibilitychange', onVisibilityChange); + + return () => { + active = false; + if (timer) clearTimeout(timer); + document.removeEventListener('visibilitychange', onVisibilityChange); + }; + }, [data, hash, setData]); +} diff --git a/explorer/src/lib/constants.ts b/explorer/src/lib/constants.ts index 21097a2e9..eb9f261c0 100644 --- a/explorer/src/lib/constants.ts +++ b/explorer/src/lib/constants.ts @@ -1,5 +1,43 @@ import { TransactionStatus } from "./types"; +/** + * Terminal statuses — transactions that have reached a final state + */ +export const TERMINAL_STATUSES: TransactionStatus[] = [ + "ACCEPTED", + "FINALIZED", + "UNDETERMINED", + "LEADER_TIMEOUT", + "VALIDATORS_TIMEOUT", + "CANCELED", +]; + +export function isTerminalStatus(status: TransactionStatus): boolean { + return TERMINAL_STATUSES.includes(status); +} + +/** + * Tab configuration for transaction list filtering + */ +export const TRANSACTION_TABS = [ + { id: "all", label: "All", statuses: null }, + { + id: "in_progress", + label: "In Progress", + statuses: ["PENDING", "ACTIVATED", "PROPOSING", "COMMITTING", "REVEALING"] as TransactionStatus[], + }, + { + id: "completed", + label: "Completed", + statuses: ["ACCEPTED", "FINALIZED"] as TransactionStatus[], + }, + { + id: "failed", + label: "Failed", + statuses: ["UNDETERMINED", "LEADER_TIMEOUT", "VALIDATORS_TIMEOUT", "CANCELED"] as TransactionStatus[], + }, +] as const; + /** * All possible transaction statuses in order of workflow */ diff --git a/explorer/src/lib/contractSchema.ts b/explorer/src/lib/contractSchema.ts new file mode 100644 index 000000000..aa14a98ed --- /dev/null +++ b/explorer/src/lib/contractSchema.ts @@ -0,0 +1,70 @@ +import { getClient } from './genlayerClient'; +import type { Address } from 'viem'; +import type { CalldataEncodable, TransactionHashVariant } from 'genlayer-js/types'; + +// --------------------------------------------------------------------------- +// Types — re-export SDK types with simpler aliases +// --------------------------------------------------------------------------- + +export type ContractParam = [name: string, type: string]; + +export interface ContractMethod { + readonly: boolean; + params: ContractParam[]; + kwparams: Record; + ret: string; + payable?: boolean; +} + +export interface ContractSchema { + ctor: { params: ContractParam[]; kwparams: Record }; + methods: Record; +} + +// --------------------------------------------------------------------------- +// Schema fetch (via SDK) +// --------------------------------------------------------------------------- + +export async function fetchContractSchema(address: string): Promise { + const client = getClient(); + const result = await client.getContractSchema(address as Address); + return result as unknown as ContractSchema; +} + +// --------------------------------------------------------------------------- +// Read call (via SDK) +// --------------------------------------------------------------------------- + +export async function callReadMethod( + address: string, + methodName: string, + args: unknown[], +): Promise { + const client = getClient(); + const result = await client.readContract({ + address: address as Address, + functionName: methodName, + args: args as CalldataEncodable[], + transactionHashVariant: 'latest-nonfinal' as TransactionHashVariant, + }); + return result; +} + +// --------------------------------------------------------------------------- +// Param parsing +// --------------------------------------------------------------------------- + +export function parseParamValue(value: string, type: string): unknown { + if (type === 'int') { + const n = Number(value); + return Number.isSafeInteger(n) ? n : BigInt(value); + } + if (type === 'bool') return value === 'true'; + if (type === 'string' || type === 'str') return value; + // "any" or complex types — try JSON parse + try { + return JSON.parse(value); + } catch { + return value; + } +} diff --git a/explorer/src/lib/fetchBackend.ts b/explorer/src/lib/fetchBackend.ts new file mode 100644 index 000000000..6fa4fac79 --- /dev/null +++ b/explorer/src/lib/fetchBackend.ts @@ -0,0 +1,19 @@ +const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:4000'; + +/** + * Server-side fetch utility that calls the backend directly, + * bypassing the Next.js middleware proxy hop. + * Only use in Server Components or Route Handlers. + */ +export async function fetchBackend( + path: string, +): Promise { + const url = `${BACKEND_URL}/api/explorer${path}`; + const res = await fetch(url, { cache: 'no-store' }); + + if (!res.ok) { + throw new Error(`Backend ${path}: ${res.status} ${res.statusText}`); + } + + return res.json() as Promise; +} diff --git a/explorer/src/lib/genlayerClient.ts b/explorer/src/lib/genlayerClient.ts new file mode 100644 index 000000000..d92355f06 --- /dev/null +++ b/explorer/src/lib/genlayerClient.ts @@ -0,0 +1,14 @@ +import { createClient } from 'genlayer-js'; +import { localnet } from 'genlayer-js/chains'; + +let client: ReturnType | null = null; + +export function getClient() { + if (!client) { + client = createClient({ + chain: localnet, + endpoint: '/api/rpc', + }); + } + return client; +} diff --git a/explorer/src/lib/jsonrpc.ts b/explorer/src/lib/jsonrpc.ts new file mode 100644 index 000000000..f6254d04e --- /dev/null +++ b/explorer/src/lib/jsonrpc.ts @@ -0,0 +1,14 @@ +let rpcId = 0; + +export async function rpcCall(method: string, params: unknown[]): Promise { + const res = await fetch('/api/rpc', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', method, params, id: ++rpcId }), + }); + const data = await res.json(); + if (data.error) { + throw new Error(data.error.message || JSON.stringify(data.error)); + } + return data.result; +} diff --git a/explorer/src/lib/resultDecoder.ts b/explorer/src/lib/resultDecoder.ts index 814d2c58b..56759086e 100644 --- a/explorer/src/lib/resultDecoder.ts +++ b/explorer/src/lib/resultDecoder.ts @@ -144,6 +144,37 @@ export function decodeResult(input: unknown): DecodedResult { return { raw: rawB64 ?? input, status, payload }; } +/** + * Decode input calldata for a call transaction. + * The calldata binary encodes [method_name, arg1, arg2, ...]. + */ +export function decodeCalldata(b64: string): { methodName: string | null; args: unknown[] } | null { + try { + const bytes = b64ToArray(b64); + if (bytes.length === 0) return null; + const decoded = abi.calldata.decode(bytes); + + // Map format: { method?: string, args: unknown[] } + if (decoded instanceof Map) { + const methodName = decoded.get('method'); + const args = decoded.get('args'); + return { + methodName: typeof methodName === 'string' ? methodName : null, + args: Array.isArray(args) ? args : [], + }; + } + + // Array format: [method_name, arg1, arg2, ...] + if (Array.isArray(decoded) && decoded.length > 0) { + return { methodName: decoded[0] as string, args: decoded.slice(1) }; + } + + return null; + } catch { + return null; + } +} + /** * Human-readable label for a result status code. */ diff --git a/explorer/src/middleware.ts b/explorer/src/proxy.ts similarity index 71% rename from explorer/src/middleware.ts rename to explorer/src/proxy.ts index e6efc531a..3f17a0c01 100644 --- a/explorer/src/middleware.ts +++ b/explorer/src/proxy.ts @@ -2,10 +2,13 @@ import { type NextRequest, NextResponse } from "next/server"; const BACKEND_URL = process.env.BACKEND_URL || "http://localhost:4000"; -export function middleware(request: NextRequest) { +export function proxy(request: NextRequest) { const { pathname, search } = request.nextUrl; - const destination = `${BACKEND_URL}/api/explorer${pathname.slice("/api".length)}${search}`; + // Let Next.js API routes handle /api/rpc + if (pathname.startsWith('/api/rpc')) return NextResponse.next(); + + const destination = `${BACKEND_URL}/api/explorer${pathname.slice("/api".length)}${search}`; return NextResponse.rewrite(new URL(destination)); } diff --git a/tests/db-sqlalchemy/explorer_queries_test.py b/tests/db-sqlalchemy/explorer_queries_test.py new file mode 100644 index 000000000..62abe1edb --- /dev/null +++ b/tests/db-sqlalchemy/explorer_queries_test.py @@ -0,0 +1,536 @@ +"""Tests for the explorer query layer (backend.protocol_rpc.explorer.queries). + +These tests run against a real PostgreSQL database via the db-sqlalchemy +Docker Compose setup. They exercise every public query function to ensure +the explorer endpoints won't break. +""" + +import base64 + +import pytest +from sqlalchemy.orm import Session + +from backend.database_handler.models import ( + CurrentState, + LLMProviderDBModel, + Transactions, + TransactionStatus, + Validators, +) +from backend.protocol_rpc.explorer import queries + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_counter = 0 + + +def _make_tx(session: Session, **overrides) -> Transactions: + """Insert a transaction with sensible defaults and return the ORM object.""" + global _counter + _counter += 1 + defaults = dict( + hash=f"0x{_counter:064x}", + status=TransactionStatus.FINALIZED, + from_address="0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + to_address="0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", + input_data=None, + data={"key": "value"}, + consensus_data=None, + nonce=_counter, + value=100, + type=2, # contract call + gaslimit=None, + leader_only=False, + r=None, + s=None, + v=None, + appeal_failed=None, + consensus_history=None, + timestamp_appeal=None, + appeal_processing_time=None, + contract_snapshot=None, + config_rotation_rounds=3, + num_of_initial_validators=None, + last_vote_timestamp=None, + rotation_count=None, + leader_timeout_validators=None, + ) + # triggered_by_hash has init=False on the model — handle separately + triggered_by_hash = overrides.pop("triggered_by_hash", None) + defaults.update(overrides) + tx = Transactions(**defaults) + session.add(tx) + session.flush() + if triggered_by_hash is not None: + tx.triggered_by_hash = triggered_by_hash + session.flush() + return tx + + +def _make_state(session: Session, address: str, **overrides) -> CurrentState: + """Insert a CurrentState row.""" + defaults = dict(id=address, data={"storage_key": 42}, balance=1000) + defaults.update(overrides) + state = CurrentState(**defaults) + session.add(state) + session.flush() + return state + + +def _make_validator(session: Session, **overrides) -> Validators: + global _counter + _counter += 1 + defaults = dict( + stake=10, + config={"temp": 0.7}, + address=f"0xVAL{_counter:036x}", + provider="openai", + model="gpt-4o", + plugin="openai-compatible", + plugin_config={"api_key_env_var": "KEY"}, + private_key=None, + ) + defaults.update(overrides) + v = Validators(**defaults) + session.add(v) + session.flush() + return v + + +def _make_provider(session: Session, **overrides) -> LLMProviderDBModel: + global _counter + _counter += 1 + defaults = dict( + provider="openai", + model=f"gpt-4o-{_counter}", + config={}, + plugin="openai-compatible", + plugin_config={}, + is_default=False, + ) + defaults.update(overrides) + p = LLMProviderDBModel(**defaults) + session.add(p) + session.flush() + return p + + +# --------------------------------------------------------------------------- +# Stats +# --------------------------------------------------------------------------- + + +class TestGetStatsCounts: + def test_empty_database(self, session: Session): + result = queries.get_stats_counts(session) + assert result == { + "totalTransactions": 0, + "totalValidators": 0, + "totalContracts": 0, + } + + def test_counts_with_data(self, session: Session): + contract_addr = "0xCONTRACT_001" + _make_state(session, contract_addr) + _make_tx(session, to_address=contract_addr, type=1) # deploy tx + _make_tx(session) # unrelated tx + _make_validator(session) + session.commit() + + result = queries.get_stats_counts(session) + assert result["totalTransactions"] == 2 + assert result["totalValidators"] == 1 + assert result["totalContracts"] == 1 + + +class TestGetStats: + def test_empty_database(self, session: Session): + result = queries.get_stats(session) + assert result["totalTransactions"] == 0 + assert result["totalValidators"] == 0 + assert result["totalContracts"] == 0 + assert result["appealedTransactions"] == 0 + assert result["finalizedTransactions"] == 0 + assert result["avgTps24h"] >= 0 + assert result["txVolume14d"] == [] or isinstance(result["txVolume14d"], list) + assert result["recentTransactions"] == [] + assert "transactionsByStatus" in result + assert "transactionsByType" in result + + def test_stats_with_transactions(self, session: Session): + contract_addr = "0xCONTRACT_002" + _make_state(session, contract_addr) + _make_tx( + session, + to_address=contract_addr, + type=1, + status=TransactionStatus.FINALIZED, + ) + _make_tx(session, status=TransactionStatus.PENDING) + _make_tx(session, status=TransactionStatus.FINALIZED, appealed=True) + _make_validator(session) + session.commit() + + result = queries.get_stats(session) + assert result["totalTransactions"] == 3 + assert result["transactionsByStatus"]["FINALIZED"] == 2 + assert result["transactionsByStatus"]["PENDING"] == 1 + assert result["transactionsByType"]["deploy"] == 1 + assert result["transactionsByType"]["call"] == 2 + assert result["appealedTransactions"] == 1 + assert result["finalizedTransactions"] == 2 + assert result["totalContracts"] == 1 + assert len(result["recentTransactions"]) == 3 + + def test_recent_transactions_limited_to_10(self, session: Session): + for _ in range(15): + _make_tx(session) + session.commit() + + result = queries.get_stats(session) + assert len(result["recentTransactions"]) == 10 + + +# --------------------------------------------------------------------------- +# Transactions (paginated) +# --------------------------------------------------------------------------- + + +class TestGetAllTransactionsPaginated: + def test_empty(self, session: Session): + result = queries.get_all_transactions_paginated(session) + assert result["transactions"] == [] + assert result["pagination"]["total"] == 0 + assert result["pagination"]["totalPages"] == 0 + + def test_pagination(self, session: Session): + for _ in range(5): + _make_tx(session) + session.commit() + + page1 = queries.get_all_transactions_paginated(session, page=1, limit=2) + assert len(page1["transactions"]) == 2 + assert page1["pagination"]["total"] == 5 + assert page1["pagination"]["totalPages"] == 3 + + page3 = queries.get_all_transactions_paginated(session, page=3, limit=2) + assert len(page3["transactions"]) == 1 + + def test_filter_single_status(self, session: Session): + _make_tx(session, status=TransactionStatus.FINALIZED) + _make_tx(session, status=TransactionStatus.PENDING) + _make_tx(session, status=TransactionStatus.PENDING) + session.commit() + + result = queries.get_all_transactions_paginated(session, status="PENDING") + assert result["pagination"]["total"] == 2 + assert all(tx["status"] == "PENDING" for tx in result["transactions"]) + + def test_filter_multi_status_comma_separated(self, session: Session): + _make_tx(session, status=TransactionStatus.FINALIZED) + _make_tx(session, status=TransactionStatus.PENDING) + _make_tx(session, status=TransactionStatus.ACCEPTED) + session.commit() + + result = queries.get_all_transactions_paginated( + session, status="PENDING,ACCEPTED" + ) + assert result["pagination"]["total"] == 2 + statuses = {tx["status"] for tx in result["transactions"]} + assert statuses == {"PENDING", "ACCEPTED"} + + def test_filter_invalid_status_returns_empty(self, session: Session): + _make_tx(session) + session.commit() + + result = queries.get_all_transactions_paginated(session, status="INVALID") + assert result["transactions"] == [] + assert result["pagination"]["total"] == 0 + + def test_search_by_hash(self, session: Session): + unique_hash = ( + "0xABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890" + ) + tx = _make_tx(session, hash=unique_hash) + _make_tx(session) + session.commit() + + result = queries.get_all_transactions_paginated(session, search="ABCDEF123456") + assert result["pagination"]["total"] == 1 + assert result["transactions"][0]["hash"] == unique_hash + + def test_search_by_address(self, session: Session): + unique_addr = "0x1234567890UNIQUE_SEARCH_ADDR" + _make_tx(session, from_address=unique_addr) + _make_tx(session) + session.commit() + + result = queries.get_all_transactions_paginated(session, search="UNIQUE_SEARCH") + assert result["pagination"]["total"] == 1 + + def test_triggered_counts(self, session: Session): + parent = _make_tx(session) + _make_tx(session, triggered_by_hash=parent.hash) + _make_tx(session, triggered_by_hash=parent.hash) + session.commit() + + result = queries.get_all_transactions_paginated(session) + parent_row = next( + tx for tx in result["transactions"] if tx["hash"] == parent.hash + ) + assert parent_row["triggered_count"] == 2 + + +# --------------------------------------------------------------------------- +# Single transaction with relations +# --------------------------------------------------------------------------- + + +class TestGetTransactionWithRelations: + def test_not_found(self, session: Session): + assert queries.get_transaction_with_relations(session, "0xnonexistent") is None + + def test_simple_transaction(self, session: Session): + tx = _make_tx(session) + session.commit() + + result = queries.get_transaction_with_relations(session, tx.hash) + assert result is not None + assert result["transaction"]["hash"] == tx.hash + assert result["triggeredTransactions"] == [] + assert result["parentTransaction"] is None + + def test_with_triggered_and_parent(self, session: Session): + parent = _make_tx(session) + child = _make_tx(session, triggered_by_hash=parent.hash) + grandchild = _make_tx(session, triggered_by_hash=child.hash) + session.commit() + + # Check child has both parent and triggered + result = queries.get_transaction_with_relations(session, child.hash) + assert result["parentTransaction"]["hash"] == parent.hash + assert len(result["triggeredTransactions"]) == 1 + assert result["triggeredTransactions"][0]["hash"] == grandchild.hash + + +# --------------------------------------------------------------------------- +# Contracts (state) +# --------------------------------------------------------------------------- + + +class TestGetAllStates: + def test_empty(self, session: Session): + result = queries.get_all_states(session) + assert result["states"] == [] + assert result["pagination"]["total"] == 0 + + def test_only_shows_deployed_contracts(self, session: Session): + """States without a deploy tx (type=1) should be excluded.""" + addr_with_deploy = "0xDEPLOYED_CONTRACT" + addr_no_deploy = "0xNO_DEPLOY_STATE" + + _make_state(session, addr_with_deploy) + _make_tx(session, to_address=addr_with_deploy, type=1) + + _make_state(session, addr_no_deploy) + _make_tx(session, to_address=addr_no_deploy, type=2) # call, not deploy + session.commit() + + result = queries.get_all_states(session) + ids = [s["id"] for s in result["states"]] + assert addr_with_deploy in ids + assert addr_no_deploy not in ids + + def test_search_filter(self, session: Session): + addr = "0xSEARCHABLE_CONTRACT" + _make_state(session, addr) + _make_tx(session, to_address=addr, type=1) + + other = "0xOTHER_CONTRACT" + _make_state(session, other) + _make_tx(session, to_address=other, type=1) + session.commit() + + result = queries.get_all_states(session, search="SEARCHABLE") + assert result["pagination"]["total"] == 1 + assert result["states"][0]["id"] == addr + + def test_pagination(self, session: Session): + for i in range(5): + addr = f"0xPAG_CONTRACT_{i:03d}" + _make_state(session, addr) + _make_tx(session, to_address=addr, type=1) + session.commit() + + result = queries.get_all_states(session, page=1, limit=2) + assert len(result["states"]) == 2 + assert result["pagination"]["totalPages"] == 3 + + def test_includes_tx_count(self, session: Session): + addr = "0xCOUNT_CONTRACT" + _make_state(session, addr) + _make_tx(session, to_address=addr, type=1) + _make_tx(session, to_address=addr, type=2) + _make_tx(session, from_address=addr, type=2) + session.commit() + + result = queries.get_all_states(session) + state_row = next(s for s in result["states"] if s["id"] == addr) + assert state_row["tx_count"] == 3 + + +class TestGetStateWithTransactions: + def test_not_found(self, session: Session): + assert queries.get_state_with_transactions(session, "0xnonexistent") is None + + def test_returns_state_and_transactions(self, session: Session): + addr = "0xSTATE_DETAIL" + _make_state(session, addr, balance=500) + _make_tx(session, to_address=addr, type=1, from_address="0xCREATOR") + _make_tx(session, to_address=addr, type=2) + session.commit() + + result = queries.get_state_with_transactions(session, addr) + assert result is not None + assert result["state"]["id"] == addr + assert result["state"]["balance"] == 500 + assert len(result["transactions"]) == 2 + assert result["creator_info"]["creator_address"] == "0xCREATOR" + + def test_extracts_contract_code(self, session: Session): + addr = "0xCODE_CONTRACT" + source = "class MyContract(gl.Contract): pass" + encoded = base64.b64encode(source.encode()).decode() + + _make_state(session, addr) + _make_tx( + session, + to_address=addr, + type=1, + data={"contract_code": encoded}, + ) + session.commit() + + result = queries.get_state_with_transactions(session, addr) + assert result["contract_code"] == source + + def test_no_contract_code(self, session: Session): + addr = "0xNO_CODE_CONTRACT" + _make_state(session, addr) + _make_tx(session, to_address=addr, type=1, data={"key": "val"}) + session.commit() + + result = queries.get_state_with_transactions(session, addr) + assert result["contract_code"] is None + + +# --------------------------------------------------------------------------- +# Address (unified lookup) +# --------------------------------------------------------------------------- + + +class TestGetAddressInfo: + def test_not_found(self, session: Session): + assert queries.get_address_info(session, "0xNOWHERE") is None + + def test_resolves_contract(self, session: Session): + addr = "0xADDR_CONTRACT" + _make_state(session, addr) + _make_tx(session, to_address=addr, type=1, from_address="0xDEPLOYER") + session.commit() + + result = queries.get_address_info(session, addr) + assert result["type"] == "CONTRACT" + assert result["address"] == addr + assert "state" in result + assert result["creator_info"]["creator_address"] == "0xDEPLOYER" + + def test_resolves_validator(self, session: Session): + v = _make_validator(session) + session.commit() + + result = queries.get_address_info(session, v.address) + assert result["type"] == "VALIDATOR" + assert result["validator"]["provider"] == v.provider + + def test_resolves_account_with_transactions(self, session: Session): + account = "0xACCOUNT_WITH_TXS" + _make_tx(session, from_address=account) + _make_tx(session, from_address=account) + session.commit() + + result = queries.get_address_info(session, account) + assert result["type"] == "ACCOUNT" + assert result["tx_count"] == 2 + assert len(result["transactions"]) == 2 + + def test_resolves_eoa_with_state_but_no_deploy(self, session: Session): + addr = "0xEOA_WITH_STATE" + _make_state(session, addr, balance=999) + session.commit() + + result = queries.get_address_info(session, addr) + assert result["type"] == "ACCOUNT" + assert result["balance"] == 999 + assert result["transactions"] == [] + + +# --------------------------------------------------------------------------- +# Validators +# --------------------------------------------------------------------------- + + +class TestGetAllValidators: + def test_empty(self, session: Session): + result = queries.get_all_validators(session) + assert result == {"validators": []} + + def test_returns_all(self, session: Session): + _make_validator(session, provider="openai", model="gpt-4o") + _make_validator(session, provider="anthropic", model="claude") + session.commit() + + result = queries.get_all_validators(session) + assert len(result["validators"]) == 2 + + def test_search(self, session: Session): + _make_validator(session, provider="openai", model="gpt-4o") + _make_validator(session, provider="anthropic", model="claude-3") + session.commit() + + result = queries.get_all_validators(session, search="anthropic") + assert len(result["validators"]) == 1 + assert result["validators"][0]["provider"] == "anthropic" + + def test_limit(self, session: Session): + for _ in range(5): + _make_validator(session) + session.commit() + + result = queries.get_all_validators(session, limit=3) + assert len(result["validators"]) == 3 + + +# --------------------------------------------------------------------------- +# Providers +# --------------------------------------------------------------------------- + + +class TestGetAllProviders: + def test_empty(self, session: Session): + result = queries.get_all_providers(session) + assert result == {"providers": []} + + def test_returns_all_ordered(self, session: Session): + _make_provider(session, provider="openai", model="gpt-4o") + _make_provider(session, provider="anthropic", model="claude-3") + session.commit() + + result = queries.get_all_providers(session) + assert len(result["providers"]) == 2 + # Ordered by provider, model + assert result["providers"][0]["provider"] == "anthropic" + assert result["providers"][1]["provider"] == "openai"