diff --git a/.github/workflows/backend_integration_tests_pr.yml b/.github/workflows/backend_integration_tests_pr.yml index c691752c3..5b065a243 100644 --- a/.github/workflows/backend_integration_tests_pr.yml +++ b/.github/workflows/backend_integration_tests_pr.yml @@ -53,8 +53,6 @@ jobs: sed -i "s/VITE_FINALITY_WINDOW=\".*\"/VITE_FINALITY_WINDOW=\"10\"/" .env sed -i "s/COMPOSE_RPC_CPU_LIMIT=\".*\"/COMPOSE_RPC_CPU_LIMIT=\"4\"/" .env sed -i "s/COMPOSE_WORKER_CPU_LIMIT=\".*\"/COMPOSE_WORKER_CPU_LIMIT=\"4\"/" .env - sed -i "s/COMPOSE_RPC_MEM_RESERVATION=\".*\"/COMPOSE_RPC_MEM_RESERVATION=\"1\"/" .env - sed -i "s/COMPOSE_WORKER_MEM_RESERVATION=\".*\"/COMPOSE_WORKER_MEM_RESERVATION=\"1\"/" .env sed -i "s/COMPOSE_RPC_MEM_LIMIT=\".*\"/COMPOSE_RPC_MEM_LIMIT=\"6gb\"/" .env sed -i "s/COMPOSE_WORKER_MEM_LIMIT=\".*\"/COMPOSE_WORKER_MEM_LIMIT=\"6gb\"/" .env sed -i "s/COMPOSE_RPC_MEM_RESERVATION=\".*\"/COMPOSE_RPC_MEM_RESERVATION=\"2gb\"/" .env @@ -120,29 +118,18 @@ jobs: consensus-worker.tags=genlayer-studio-consensus-worker:latest load: true - # Start services without rebuilding images; CI override mounts GenVM precompile cache + # Start services without rebuilding images; CI override provides fast healthcheck timings. + # --wait blocks until all healthchecks pass (jsonrpc /ready + consensus-worker /health). - name: Run Docker Compose - run: docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d --no-build database-migration jsonrpc consensus-worker - - - name: Wait for services to be up timeout-minutes: 5 + run: docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d --no-build --wait database-migration jsonrpc consensus-worker + + - name: Verify services are ready run: | - timeout=60 - counter=0 - while [[ "$counter" -lt "$timeout" ]]; do - if curl -sS -X POST -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","method":"ping","params":[],"id":1}' http://0.0.0.0:4000/api 2>/dev/null | grep -q "OK"; then - echo "RPC server is up!" - break - else - echo "Waiting for RPC server... ($counter/$timeout)" - sleep 2 - counter=$((counter+1)) - fi - done - if [[ "$counter" -ge "$timeout" ]]; then - echo "Error: Timeout while waiting for RPC server" - exit 1 - fi + echo "Services passed healthchecks, verifying RPC endpoint..." + curl -sS -X POST -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"ping","params":[],"id":1}' \ + http://0.0.0.0:4000/api - name: Run tests (parallel) run: gltest --contracts-dir . --default-wait-retries 140 tests/integration/ -svv -n 8 --ignore=tests/integration/test_validators.py diff --git a/.github/workflows/load-test-oha.yml b/.github/workflows/load-test-oha.yml index 051f2e2ee..ddb982e3a 100644 --- a/.github/workflows/load-test-oha.yml +++ b/.github/workflows/load-test-oha.yml @@ -30,6 +30,7 @@ jobs: - run: true load-test: + name: Load Tests needs: triggers if: ${{ needs.triggers.outputs.is_pull_request_opened == 'true' || needs.triggers.outputs.is_pull_request_review_approved == 'true' || needs.triggers.outputs.is_pull_request_labeled_with_run_tests == 'true' }} runs-on: ubuntu-latest @@ -84,193 +85,111 @@ jobs: restore-keys: | ${{ runner.os }}-buildx- + - name: Prepare GenVM cache directory + run: mkdir -p /tmp/genvm-cache/pc && chmod -R 0777 /tmp/genvm-cache + - name: Run Docker Compose with multiple workers - run: docker compose up -d + timeout-minutes: 10 + run: docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d --wait env: CONSENSUS_WORKERS: 3 - - name: Wait for services to be up + - name: Verify services are ready run: | - echo "Waiting for frontend container to be ready..." - timeout=120 - counter=0 - frontend_ready=false - - # Wait for frontend to be ready - while [[ "$counter" -lt "$timeout" ]]; do - # Check if frontend container logs show it's ready - if docker compose logs frontend 2>/dev/null | tail -20 | grep -q "➜ Local: http://localhost:8080/"; then - echo "✅ Frontend is ready!" - frontend_ready=true - break - else - echo "Waiting for frontend... ($counter/$timeout)" - sleep 5 - counter=$((counter+1)) - fi - done - - if [ "$frontend_ready" = false ]; then - echo "⚠️ Frontend may not be fully ready after $timeout seconds" - echo "Checking if at least the RPC server is responding..." - fi - - # Also verify RPC server is responding - echo "Checking RPC server..." - rpc_counter=0 - rpc_timeout=60 - while [[ "$rpc_counter" -lt "$rpc_timeout" ]]; do - if curl -X POST -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","method":"ping","params":[],"id":1}' http://localhost:4000/api | grep -q "OK"; then - echo "✅ RPC server is up!" - break - else - echo "Waiting for RPC server... ($rpc_counter/$rpc_timeout)" - sleep 5 - rpc_counter=$((rpc_counter+1)) - fi - done - - # Fail if the RPC service didn't start within the timeout - if [[ "$rpc_counter" -ge "$rpc_timeout" ]]; then - echo "Error: Timeout while waiting for RPC server" - docker compose logs - exit 1 - fi - - # If frontend is ready, we're good to go - if [ "$frontend_ready" = true ]; then - echo "✅ All services are ready!" - else - # If frontend isn't ready but RPC is, wait a bit more - echo "Waiting additional 3 seconds for all services to stabilize..." - sleep 3 - fi + echo "Services passed healthchecks, verifying RPC endpoint..." + curl -sS -X POST -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"ping","params":[],"id":1}' \ + http://localhost:4000/api - name: Verify Chain ID run: | echo "Verifying chain ID is readable..." - max_retries=5 - retry_count=0 - chain_id="" - - while [[ "$retry_count" -lt "$max_retries" ]]; do - echo "Attempt $((retry_count + 1))/$max_retries to read chain ID..." - - # Try to get the chain ID + for attempt in 1 2 3 4 5; do + echo "Attempt $attempt/5..." response=$(curl -s -X POST http://localhost:4000/api \ -H "Content-Type: application/json" \ - -d '{ - "jsonrpc": "2.0", - "method": "eth_chainId", - "params": [], - "id": 1 - }') - + -d '{"jsonrpc":"2.0","method":"eth_chainId","params":[],"id":1}') echo "Response: $response" - # Check if we got a valid chain ID response - if echo "$response" | grep -q '"result"'; then - # Handle both formatted and unformatted JSON - chain_id=$(echo "$response" | grep -o '"result"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*"result"[[:space:]]*:[[:space:]]*"//;s/"$//') - if [ ! -z "$chain_id" ]; then - echo "✅ Successfully read chain ID: $chain_id" - break - fi + chain_id=$(python3 -c "import json,sys; r=json.loads(sys.argv[1]); print(r.get('result',''))" "$response" 2>/dev/null || true) + if [ -n "$chain_id" ]; then + echo "Chain ID: $chain_id" + break fi - retry_count=$((retry_count + 1)) - - if [[ "$retry_count" -lt "$max_retries" ]]; then - echo "Failed to read chain ID, waiting 10 seconds before retry..." - sleep 10 + if [ "$attempt" -lt 5 ]; then + echo "Retrying in 5s..." + sleep 5 fi done - # Check if we successfully got the chain ID if [ -z "$chain_id" ]; then - echo "❌ ERROR: Failed to read chain ID after $max_retries attempts" - echo "The blockchain service may not be properly initialized" + echo "ERROR: Failed to read chain ID after 5 attempts" exit 1 fi - echo "Chain ID verification successful, proceeding with tests..." - - name: Setup validators run: | - echo "Creating validators with 5 second delays..." + echo "Creating 5 validators with retry logic..." + created=0 for i in {1..5}; do - echo "Creating validator $i/5..." - response=$(curl -s -X POST http://localhost:4000/api \ - -H "Content-Type: application/json" \ - -d '{ - "jsonrpc": "2.0", - "method": "sim_createRandomValidator", - "params": [1], - "id": '"$i"' - }') - - # Check if creation was successful - if echo "$response" | grep -q '"result"'; then - echo "✅ Validator $i created successfully" - echo "Response: $response" - else - echo "❌ Failed to create validator $i" - echo "Response: $response" - fi - - # Wait 5 seconds between validator creation - if [ $i -lt 5 ]; then - echo "Waiting 5 seconds before next validator..." - sleep 5 - fi - done + success=false + for attempt in 1 2 3; do + echo "Creating validator $i/5 (attempt $attempt/3)..." + response=$(curl -s -X POST http://localhost:4000/api \ + -H "Content-Type: application/json" \ + -d "{\"jsonrpc\":\"2.0\",\"method\":\"sim_createRandomValidator\",\"params\":[1],\"id\":$i}") + + has_result=$(python3 -c "import json,sys; r=json.loads(sys.argv[1]); print('yes' if 'result' in r else 'no')" "$response" 2>/dev/null || echo "no") + if [ "$has_result" = "yes" ]; then + echo "Validator $i created" + created=$((created + 1)) + success=true + break + fi - # Wait for validators to be fully registered - echo "Waiting 10 seconds for validators to be registered..." - sleep 10 + echo "Failed: $response" + if [ "$attempt" -lt 3 ]; then + echo "Retrying in 3s..." + sleep 3 + fi + done - # Verify validators were created - echo "Verifying validators..." - response=$(curl -s -X POST http://localhost:4000/api \ - -H "Content-Type: application/json" \ - -d '{ - "jsonrpc": "2.0", - "method": "sim_countValidators", - "params": [], - "id": 100 - }') + if [ "$success" = false ]; then + echo "ERROR: Could not create validator $i after 3 attempts" + exit 1 + fi - echo "Count response: $response" + # Brief pause between validators + if [ "$i" -lt 5 ]; then sleep 2; fi + done - # Try different parsing approaches - count=$(echo "$response" | grep -o '"result"[[:space:]]*:[[:space:]]*[0-9]*' | grep -o '[0-9]*' | tail -1) + echo "Waiting 5 seconds for validators to register..." + sleep 5 - # If count is empty, try without spaces - if [ -z "$count" ]; then - count=$(echo "$response" | grep -o '"result":[0-9]*' | grep -o '[0-9]*' | tail -1) - fi + # Verify validator count with retry + for attempt in 1 2 3; do + response=$(curl -s -X POST http://localhost:4000/api \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"sim_countValidators","params":[],"id":100}') + count=$(python3 -c "import json,sys; r=json.loads(sys.argv[1]); print(r.get('result',0))" "$response" 2>/dev/null || echo "0") + echo "Validator count: $count (attempt $attempt/3)" - # Default to 0 if still empty - if [ -z "$count" ]; then - count=0 - fi + if [ "$count" -ge 5 ]; then + echo "All $count validators registered" + break + fi - echo "Total validators: $count" + if [ "$attempt" -lt 3 ]; then sleep 3; fi + done if [ "$count" -lt 5 ]; then - echo "❌ Error: Expected at least 5 validators, found $count" - echo "Trying to list all validators for debugging..." + echo "ERROR: Expected at least 5 validators, found $count" curl -s -X POST http://localhost:4000/api \ -H "Content-Type: application/json" \ - -d '{ - "jsonrpc": "2.0", - "method": "sim_getAllValidators", - "params": [], - "id": 101 - }' + -d '{"jsonrpc":"2.0","method":"sim_getAllValidators","params":[],"id":101}' exit 1 fi - echo "✅ Successfully created $count validators" - name: Run Load Test - Contract Deploy and Read run: | @@ -497,31 +416,46 @@ jobs: run: | echo "Re-creating validators (previous step cleaned them up)..." for i in {1..5}; do - echo "Creating validator $i/5..." - response=$(curl -s -X POST http://localhost:4000/api \ - -H "Content-Type: application/json" \ - -d '{ - "jsonrpc": "2.0", - "method": "sim_createRandomValidator", - "params": [1], - "id": '"$i"' - }') - if echo "$response" | grep -q '"result"'; then - echo "✅ Validator $i created" - else - echo "❌ Failed to create validator $i: $response" + success=false + for attempt in 1 2 3; do + echo "Creating validator $i/5 (attempt $attempt/3)..." + response=$(curl -s -X POST http://localhost:4000/api \ + -H "Content-Type: application/json" \ + -d "{\"jsonrpc\":\"2.0\",\"method\":\"sim_createRandomValidator\",\"params\":[1],\"id\":$i}") + + has_result=$(python3 -c "import json,sys; r=json.loads(sys.argv[1]); print('yes' if 'result' in r else 'no')" "$response" 2>/dev/null || echo "no") + if [ "$has_result" = "yes" ]; then + echo "Validator $i created" + success=true + break + fi + + echo "Failed: $response" + if [ "$attempt" -lt 3 ]; then sleep 3; fi + done + + if [ "$success" = false ]; then + echo "ERROR: Could not create validator $i after 3 attempts" + exit 1 fi - sleep 2 + + if [ "$i" -lt 5 ]; then sleep 2; fi done - echo "Waiting 10 seconds for validators to be registered..." - sleep 10 + echo "Waiting 5 seconds for validators to register..." + sleep 5 # Verify validators exist response=$(curl -s -X POST http://localhost:4000/api \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","method":"sim_countValidators","params":[],"id":100}') - echo "Validator count: $response" + count=$(python3 -c "import json,sys; r=json.loads(sys.argv[1]); print(r.get('result',0))" "$response" 2>/dev/null || echo "0") + echo "Validator count: $count" + + if [ "$count" -lt 5 ]; then + echo "ERROR: Expected at least 5 validators, found $count" + exit 1 + fi - name: Run State Integrity Test (lost update detection) run: | @@ -529,6 +463,10 @@ jobs: pip install genlayer-py requests python3 test_state_integrity.py http://localhost:4000/api --txs 20 + - name: Dump Docker Compose logs + run: docker compose logs + if: failure() + - name: Shutdown Docker Compose if: always() run: docker compose down -v diff --git a/backend/consensus/worker.py b/backend/consensus/worker.py index ee25e65a9..613860cf9 100644 --- a/backend/consensus/worker.py +++ b/backend/consensus/worker.py @@ -188,12 +188,12 @@ async def claim_next_finalization(self, session: Session) -> Optional[dict]: AND NOT EXISTS ( -- Ensure no other transaction for same contract is being processed SELECT 1 FROM transactions t2 - WHERE t2.to_address = t.to_address + WHERE t2.to_address IS NOT DISTINCT FROM t.to_address AND t2.blocked_at IS NOT NULL AND t2.blocked_at > NOW() - CAST(:timeout AS INTERVAL) AND t2.hash != t.hash ) - AND pg_try_advisory_xact_lock(hashtext(t.to_address)) + AND pg_try_advisory_xact_lock(hashtext(COALESCE(t.to_address, t.hash))) ORDER BY t.created_at ASC FOR UPDATE SKIP LOCKED ), @@ -293,13 +293,13 @@ async def claim_next_appeal(self, session: Session) -> Optional[dict]: AND NOT EXISTS ( -- Ensure no other appeal for same contract is being processed SELECT 1 FROM transactions t2 - WHERE t2.to_address = t.to_address + WHERE t2.to_address IS NOT DISTINCT FROM t.to_address AND t2.appealed = true AND t2.blocked_at IS NOT NULL AND t2.blocked_at > NOW() - CAST(:timeout AS INTERVAL) AND t2.hash != t.hash ) - AND pg_try_advisory_xact_lock(hashtext(t.to_address)) + AND pg_try_advisory_xact_lock(hashtext(COALESCE(t.to_address, t.hash))) ORDER BY t.created_at ASC FOR UPDATE SKIP LOCKED ), @@ -400,7 +400,7 @@ async def claim_next_transaction(self, session: Session) -> Optional[dict]: AND NOT EXISTS ( -- Ensure no other transaction for same contract is being processed SELECT 1 FROM transactions t2 - WHERE t2.to_address = t.to_address + WHERE t2.to_address IS NOT DISTINCT FROM t.to_address AND t2.blocked_at IS NOT NULL AND t2.blocked_at > NOW() - CAST(:timeout AS INTERVAL) AND t2.hash != t.hash @@ -408,7 +408,9 @@ async def claim_next_transaction(self, session: Session) -> Optional[dict]: -- Atomic per-contract lock to close TOCTOU window in NOT EXISTS. -- Under READ COMMITTED, two workers can both pass NOT EXISTS before -- either commits blocked_at. This advisory lock prevents that race. - AND pg_try_advisory_xact_lock(hashtext(t.to_address)) + -- COALESCE handles NULL to_address (e.g. burn transactions) by + -- falling back to the tx hash, giving each such tx its own lock. + AND pg_try_advisory_xact_lock(hashtext(COALESCE(t.to_address, t.hash))) ORDER BY CASE WHEN t.type = 3 THEN 0 ELSE 1 END, t.created_at ASC FOR UPDATE SKIP LOCKED ), diff --git a/backend/protocol_rpc/explorer/queries.py b/backend/protocol_rpc/explorer/queries.py index 37d0f18ab..663ca63d3 100644 --- a/backend/protocol_rpc/explorer/queries.py +++ b/backend/protocol_rpc/explorer/queries.py @@ -5,7 +5,7 @@ from datetime import datetime, timedelta, timezone from typing import Optional -from sqlalchemy import func, or_, select +from sqlalchemy import asc, desc, func, or_, select, union from sqlalchemy.orm import Session, defer from backend.database_handler.models import ( @@ -20,16 +20,8 @@ def _serialize_tx( tx: Transactions, triggered_count: int | None = None, - *, - include_snapshot: bool = True, ) -> dict: - """Serialize a Transactions ORM object to a dict matching the raw SQL column output. - - When *include_snapshot* is False the heavy ``contract_snapshot`` column is - omitted (set to ``None``). Callers should pair this with - ``defer(Transactions.contract_snapshot)`` on the query to avoid loading the - blob at all. - """ + """Serialize a Transactions ORM object to a dict for the explorer API.""" d = { "hash": tx.hash, "status": tx.status.value if tx.status else None, @@ -52,7 +44,6 @@ def _serialize_tx( "consensus_history": tx.consensus_history, "timestamp_appeal": tx.timestamp_appeal, "appeal_processing_time": tx.appeal_processing_time, - "contract_snapshot": tx.contract_snapshot if include_snapshot else None, "config_rotation_rounds": tx.config_rotation_rounds, "num_of_initial_validators": tx.num_of_initial_validators, "last_vote_timestamp": tx.last_vote_timestamp, @@ -74,13 +65,19 @@ def _serialize_tx( return d -def _serialize_state(state: CurrentState, *, tx_count: int | None = None) -> dict: +def _serialize_state( + state: CurrentState, + *, + tx_count: int | None = None, + include_data: bool = True, +) -> dict: d = { "id": state.id, - "data": state.data, "balance": state.balance, "updated_at": state.updated_at.isoformat() if state.updated_at else None, } + if include_data: + d["data"] = state.data if tx_count is not None: d["tx_count"] = tx_count return d @@ -247,7 +244,10 @@ def get_stats(session: Session) -> dict: "avgTps24h": avg_tps_24h, "txVolume14d": tx_volume_14d, "recentTransactions": [ - _serialize_tx(tx, include_snapshot=False) for tx in recent + _serialize_tx( + tx, + ) + for tx in recent ], } @@ -265,8 +265,16 @@ def get_all_transactions_paginated( search: Optional[str] = None, from_date: Optional[str] = None, to_date: Optional[str] = None, + address: Optional[str] = None, ) -> dict: filters = [] + if address: + filters.append( + or_( + Transactions.from_address == address, + Transactions.to_address == address, + ) + ) if status: # Support comma-separated status values for multi-status filtering status_values = [s.strip() for s in status.split(",") if s.strip()] @@ -338,7 +346,10 @@ def get_all_transactions_paginated( return { "transactions": [ - _serialize_tx(tx, triggered_counts.get(tx.hash, 0), include_snapshot=False) + _serialize_tx( + tx, + triggered_counts.get(tx.hash, 0), + ) for tx in txs ], "pagination": { @@ -356,11 +367,14 @@ def get_all_transactions_paginated( def get_transaction_with_relations(session: Session, tx_hash: str) -> Optional[dict]: - tx = session.query(Transactions).filter(Transactions.hash == tx_hash).first() + tx = ( + session.query(Transactions) + .options(*_HEAVY_TX_COLUMNS) + .filter(Transactions.hash == tx_hash) + .first() + ) if not tx: return None - - # Triggered/parent don't need the snapshot blob either. triggered = ( session.query(Transactions) .options(*_HEAVY_TX_COLUMNS) @@ -381,10 +395,17 @@ def get_transaction_with_relations(session: Session, tx_hash: str) -> Optional[d return { "transaction": _serialize_tx(tx), "triggeredTransactions": [ - _serialize_tx(t, include_snapshot=False) for t in triggered + _serialize_tx( + t, + ) + for t in triggered ], "parentTransaction": ( - _serialize_tx(parent, include_snapshot=False) if parent else None + _serialize_tx( + parent, + ) + if parent + else None ), } @@ -407,27 +428,6 @@ def get_all_states( sort_by: Optional[str] = None, sort_order: Optional[str] = "desc", ) -> dict: - # Subquery: count transactions where to_address or from_address matches state id - tx_filter = or_( - Transactions.to_address == CurrentState.id, - Transactions.from_address == CurrentState.id, - ) - tx_count_sq = ( - session.query(func.count()) - .select_from(Transactions) - .filter(tx_filter) - .correlate(CurrentState) - .scalar_subquery() - ) - - # Subquery: earliest transaction timestamp (proxy for contract creation time) - created_at_sq = ( - session.query(func.min(Transactions.created_at)) - .filter(tx_filter) - .correlate(CurrentState) - .scalar_subquery() - ) - # Only show addresses that have a deploy transaction (type 1) targeting them deploy_addresses = ( session.query(Transactions.to_address) @@ -435,47 +435,169 @@ def get_all_states( .distinct() .subquery() ) + base_filter = CurrentState.id.in_(select(deploy_addresses.c.to_address)) - q = session.query( - CurrentState, - tx_count_sq.label("tx_count"), - created_at_sq.label("created_at"), - ).filter(CurrentState.id.in_(select(deploy_addresses.c.to_address))) + # --- Total count (lightweight, no correlated subqueries) --- + count_q = session.query(func.count()).select_from(CurrentState).filter(base_filter) + if search: + count_q = count_q.filter(CurrentState.id.ilike(f"%{search}%")) + total = count_q.scalar() or 0 + + if total == 0: + return _empty_page(page, limit) + + order_dir = asc if sort_order == "asc" else desc + + if sort_by in ("tx_count", "created_at"): + # Pre-aggregate tx stats per contract in one pass (no correlated subqueries). + # Count to_address and from_address matches separately, subtract overlap + # (txs where to == from) to avoid double-counting. + to_stats = ( + session.query( + Transactions.to_address.label("addr"), + func.count().label("cnt"), + func.min(Transactions.created_at).label("min_ts"), + ) + .group_by(Transactions.to_address) + .subquery() + ) + from_stats = ( + session.query( + Transactions.from_address.label("addr"), + func.count().label("cnt"), + func.min(Transactions.created_at).label("min_ts"), + ) + .group_by(Transactions.from_address) + .subquery() + ) + # Overlap: txs where to_address == from_address (self-referencing). + overlap_stats = ( + session.query( + Transactions.to_address.label("addr"), + func.count().label("cnt"), + ) + .filter(Transactions.to_address == Transactions.from_address) + .group_by(Transactions.to_address) + .subquery() + ) + + tx_count_col = ( + func.coalesce(to_stats.c.cnt, 0) + + func.coalesce(from_stats.c.cnt, 0) + - func.coalesce(overlap_stats.c.cnt, 0) + ) + created_at_col = func.least( + func.coalesce(to_stats.c.min_ts, from_stats.c.min_ts), + func.coalesce(from_stats.c.min_ts, to_stats.c.min_ts), + ) + + q = ( + session.query( + CurrentState, + tx_count_col.label("tx_count"), + created_at_col.label("created_at"), + ) + .outerjoin(to_stats, CurrentState.id == to_stats.c.addr) + .outerjoin(from_stats, CurrentState.id == from_stats.c.addr) + .outerjoin(overlap_stats, CurrentState.id == overlap_stats.c.addr) + .filter(base_filter) + ) + if search: + q = q.filter(CurrentState.id.ilike(f"%{search}%")) + + sort_col = tx_count_col if sort_by == "tx_count" else created_at_col + q = q.order_by(order_dir(sort_col), order_dir(CurrentState.id)) + q = q.offset((page - 1) * limit).limit(limit) + rows = q.all() + + return { + "states": [ + { + **_serialize_state(state, tx_count=tx_count, include_data=False), + "created_at": created_at.isoformat() if created_at else None, + } + for state, tx_count, created_at in rows + ], + "pagination": _pagination(page, limit, total), + } + + # Default: sort by updated_at — paginate first (fast), then batch-fetch stats. + q = session.query(CurrentState).filter(base_filter) if search: q = q.filter(CurrentState.id.ilike(f"%{search}%")) - total = q.count() + q = q.order_by(order_dir(CurrentState.updated_at), order_dir(CurrentState.id)) + q = q.offset((page - 1) * limit).limit(limit) + states = q.all() + + if not states: + return _empty_page(page, limit, total) + + # Batch-fetch tx stats for just this page of contracts. + page_ids = [s.id for s in states] + stats_map = _batch_contract_stats(session, page_ids) + + def _build_state_row(state: CurrentState) -> dict: + tx_count, created_at = stats_map.get(state.id, (0, None)) + return { + **_serialize_state(state, tx_count=tx_count, include_data=False), + "created_at": created_at.isoformat() if created_at else None, + } - # Determine sort column - sort_columns = { - "tx_count": tx_count_sq, - "created_at": created_at_sq, - "updated_at": CurrentState.updated_at, + return { + "states": [_build_state_row(state) for state in states], + "pagination": _pagination(page, limit, total), } - sort_col = sort_columns.get(sort_by, CurrentState.updated_at) - if sort_order == "asc": - q = q.order_by(sort_col.asc()) - else: - q = q.order_by(sort_col.desc()) - q = q.offset((page - 1) * limit).limit(limit) - rows = q.all() + +def _batch_contract_stats( + session: Session, contract_ids: list[str] +) -> dict[str, tuple[int, Optional[datetime]]]: + """Fetch tx_count and earliest created_at for a batch of contract addresses. + + Returns a dict mapping contract_id -> (tx_count, created_at). + """ + # Use union (not union_all) of to/from to deduplicate txs where + # to_address == from_address for the same contract. + to_q = session.query( + Transactions.hash.label("tx_hash"), + Transactions.to_address.label("addr"), + Transactions.created_at.label("created_at"), + ).filter(Transactions.to_address.in_(contract_ids)) + + from_q = session.query( + Transactions.hash.label("tx_hash"), + Transactions.from_address.label("addr"), + Transactions.created_at.label("created_at"), + ).filter(Transactions.from_address.in_(contract_ids)) + + # union (not union_all) deduplicates rows with same (hash, addr, created_at), + # preventing double-count when to_address == from_address for the same tx. + combined = union(to_q, from_q).subquery() + rows = ( + session.query( + combined.c.addr, + func.count().label("tx_count"), + func.min(combined.c.created_at).label("created_at"), + ) + .group_by(combined.c.addr) + .all() + ) + return {row.addr: (int(row.tx_count), row.created_at) for row in rows} + + +def _pagination(page: int, limit: int, total: int) -> dict: return { - "states": [ - { - **_serialize_state(state, tx_count=tx_count), - "created_at": created_at.isoformat() if created_at else None, - } - for state, tx_count, created_at in rows - ], - "pagination": { - "page": page, - "limit": limit, - "total": total, - "totalPages": (total + limit - 1) // limit if total > 0 else 0, - }, + "page": page, + "limit": limit, + "total": total, + "totalPages": (total + limit - 1) // limit if total > 0 else 0, } +def _empty_page(page: int, limit: int, total: int = 0) -> dict: + return {"states": [], "pagination": _pagination(page, limit, total)} + + def _extract_contract_code(session: Session, state_id: str) -> Optional[str]: """Find the contract source code for a given contract address. @@ -509,15 +631,23 @@ def get_state_with_transactions(session: Session, state_id: str) -> Optional[dic if not state: return None + addr_filter = or_( + Transactions.to_address == state_id, + Transactions.from_address == state_id, + ) + + tx_count = ( + session.query(func.count()) + .select_from(Transactions) + .filter(addr_filter) + .scalar() + or 0 + ) + txs = ( session.query(Transactions) .options(*_HEAVY_TX_COLUMNS) - .filter( - or_( - Transactions.to_address == state_id, - Transactions.from_address == state_id, - ) - ) + .filter(addr_filter) .order_by(Transactions.created_at.desc()) .limit(50) .all() @@ -546,8 +676,9 @@ def get_state_with_transactions(session: Session, state_id: str) -> Optional[dic } return { - "state": _serialize_state(state), - "transactions": [_serialize_tx(tx, include_snapshot=False) for tx in txs], + "state": _serialize_state(state, include_data=False), + "tx_count": tx_count, + "transactions": [_serialize_tx(tx) for tx in txs], "contract_code": contract_code, "creator_info": creator_info, } @@ -642,7 +773,10 @@ def get_address_info(session: Session, address: str) -> Optional[dict]: "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 + _serialize_tx( + tx, + ) + for tx in recent_txs ], } diff --git a/backend/protocol_rpc/explorer/router.py b/backend/protocol_rpc/explorer/router.py index 762e82623..3fc369fa4 100644 --- a/backend/protocol_rpc/explorer/router.py +++ b/backend/protocol_rpc/explorer/router.py @@ -41,9 +41,10 @@ def get_transactions( search: Optional[str] = None, from_date: Optional[str] = None, to_date: Optional[str] = None, + address: Optional[str] = None, ): return queries.get_all_transactions_paginated( - session, page, limit, status, search, from_date, to_date + session, page, limit, status, search, from_date, to_date, address ) diff --git a/backend/validators/__init__.py b/backend/validators/__init__.py index bff1328d5..525d47c56 100644 --- a/backend/validators/__init__.py +++ b/backend/validators/__init__.py @@ -275,8 +275,6 @@ async def _change_providers_from_snapshot(self, snap: Snapshot): await self._change_providers_from_snapshot_locked(snap) async def _change_providers_from_snapshot_locked(self, snap: Snapshot): - self._cached_snapshot = None - new_providers: dict[str, LLMConfig] = {} for i in snap.nodes: @@ -306,12 +304,25 @@ async def _change_providers_from_snapshot_locked(self, snap: Snapshot): "key": f"${{ENV[{key_env}]}}", } - await self.genvm_manager.stop_module("llm") - new_llm_config = deepcopy(self.genvm_manager.llm_config_base) - new_llm_config["backends"] = new_providers - await self.genvm_manager.start_module( - "llm", new_llm_config, {"allow_empty_backends": True} - ) + # Invalidate snapshot only after building config, not before. + # If start_module fails, we preserve the previous snapshot so the + # worker can keep processing transactions instead of going idle. + previous_snapshot = self._cached_snapshot + self._cached_snapshot = None + try: + await self.genvm_manager.stop_module("llm") + new_llm_config = deepcopy(self.genvm_manager.llm_config_base) + new_llm_config["backends"] = new_providers + await self.genvm_manager.start_module( + "llm", new_llm_config, {"allow_empty_backends": True} + ) + except Exception: + # Restore previous snapshot so the worker isn't permanently broken + self._cached_snapshot = previous_snapshot + logger.exception( + "Failed to restart LLM module — restoring previous snapshot" + ) + raise self._cached_snapshot = snap diff --git a/docker-compose.ci.yml b/docker-compose.ci.yml index 266abb5b5..970623f65 100644 --- a/docker-compose.ci.yml +++ b/docker-compose.ci.yml @@ -1,10 +1,22 @@ -# CI-only overrides: mount host GenVM precompile cache into containers -# Usage: docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d ... +# CI-only overrides: mount host GenVM precompile cache and use fast healthcheck timings. +# Usage: docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d --wait ... services: jsonrpc: volumes: - ${GENVM_CACHE_DIR:-/tmp/genvm-cache}:/genvm-cache + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:${RPCPORT:-4000}/ready"] + interval: 5s + timeout: 5s + retries: 36 + start_period: 30s consensus-worker: volumes: - ${GENVM_CACHE_DIR:-/tmp/genvm-cache}:/genvm-cache + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:4001/health"] + interval: 5s + timeout: 5s + retries: 24 + start_period: 10s diff --git a/explorer/src/app/DashboardSections.tsx b/explorer/src/app/DashboardSections.tsx index 20cf6b31a..67e37b331 100644 --- a/explorer/src/app/DashboardSections.tsx +++ b/explorer/src/app/DashboardSections.tsx @@ -1,5 +1,5 @@ import { cache } from 'react'; -import Link from 'next/link'; +import Link from '@/components/AppLink'; import { fetchBackend } from '@/lib/fetchBackend'; import { StatCard } from '@/components/StatCard'; import { SparklineChart } from '@/components/SparklineChart'; diff --git a/explorer/src/app/address/[addr]/AddressContent.tsx b/explorer/src/app/address/[addr]/AddressContent.tsx index 0f81de7b7..002835170 100644 --- a/explorer/src/app/address/[addr]/AddressContent.tsx +++ b/explorer/src/app/address/[addr]/AddressContent.tsx @@ -1,6 +1,6 @@ 'use client'; -import Link from 'next/link'; +import Link from '@/components/AppLink'; import { formatDistanceToNow, format } from 'date-fns'; import { Transaction, Validator, CurrentState } from '@/lib/types'; @@ -18,6 +18,7 @@ import { ContractInteraction } from '@/components/ContractInteraction'; import { StatItem } from '@/components/StatItem'; import { ArrowLeft, + ArrowDownNarrowWide, Wallet, Users, ArrowRightLeft, @@ -60,6 +61,7 @@ export function AddressContent({ addr, data }: { addr: string; data: AddressInfo function AccountView({ address, data }: { address: string; data: AddressInfo }) { const txs = data.transactions || []; + const txCount = data.tx_count ?? txs.length; return (
@@ -69,7 +71,7 @@ function AccountView({ address, data }: { address: string; data: AddressInfo })
} 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)} /> + } iconBg="bg-blue-100 dark:bg-blue-950" label="Transactions" value={txCount.toLocaleString()} />

First Tx

@@ -90,12 +92,23 @@ function AccountView({ address, data }: { address: string; data: AddressInfo }) - Transactions ({data.tx_count ?? txs.length}) + Transactions ({txCount.toLocaleString()}) +

+ Latest {txs.length} from a total of{' '} + {txCount.toLocaleString()} transactions +
+ {txCount > txs.length && ( +
+ + VIEW ALL TRANSACTIONS → + +
+ )} @@ -110,6 +123,7 @@ function AccountView({ address, data }: { address: string; data: AddressInfo }) function ContractView({ address, data }: { address: string; data: AddressInfo }) { const state = data.state; const transactions = data.transactions || []; + const txCount = data.tx_count ?? transactions.length; const contract_code = data.contract_code; const creator_info = data.creator_info; @@ -123,7 +137,7 @@ function ContractView({ address, data }: { address: string; data: AddressInfo }) {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-blue-100 dark:bg-blue-950" label="Transactions" value={txCount.toLocaleString()} /> } iconBg="bg-muted" label="Last Updated" value={state.updated_at ? formatDistanceToNow(new Date(state.updated_at), { addSuffix: true }) : 'Unknown'} small /> )} @@ -164,23 +178,29 @@ function ContractView({ address, data }: { address: string; data: AddressInfo }) - Transactions ({data.tx_count ?? transactions.length}) + Transactions ({txCount.toLocaleString()}) Contract - {state?.data && Object.keys(state.data).length > 0 && ( - - - State - - )} +
+ + Latest {transactions.length} from a total of{' '} + {txCount.toLocaleString()} transactions +
+ {txCount > transactions.length && ( +
+ + VIEW ALL TRANSACTIONS → + +
+ )}
@@ -188,17 +208,6 @@ function ContractView({ address, data }: { address: string; data: AddressInfo }) - {state?.data && Object.keys(state.data).length > 0 && ( - - - -
- -
-
-
-
- )}
); diff --git a/explorer/src/app/address/[addr]/page.tsx b/explorer/src/app/address/[addr]/page.tsx index a0bb626d9..e1b71547c 100644 --- a/explorer/src/app/address/[addr]/page.tsx +++ b/explorer/src/app/address/[addr]/page.tsx @@ -1,4 +1,4 @@ -import Link from 'next/link'; +import Link from '@/components/AppLink'; import { fetchBackend } from '@/lib/fetchBackend'; import { AddressContent, type AddressInfo } from './AddressContent'; import { Card, CardContent } from '@/components/ui/card'; diff --git a/explorer/src/app/contracts/page.tsx b/explorer/src/app/contracts/page.tsx index 50fc6be9c..44cf6f6fd 100644 --- a/explorer/src/app/contracts/page.tsx +++ b/explorer/src/app/contracts/page.tsx @@ -106,7 +106,6 @@ function StateContent() { Transactions - State Fields
)} - {tx.contract_snapshot && ( -
-

Contract Snapshot

-
- -
-
- )} - {(tx.r !== null || tx.s !== null || tx.v !== null) && (

Signature

diff --git a/explorer/src/app/transactions/[hash]/components/OverviewTab.tsx b/explorer/src/app/transactions/[hash]/components/OverviewTab.tsx index 8fcf90b5a..03bdf750c 100644 --- a/explorer/src/app/transactions/[hash]/components/OverviewTab.tsx +++ b/explorer/src/app/transactions/[hash]/components/OverviewTab.tsx @@ -1,6 +1,6 @@ 'use client'; -import Link from 'next/link'; +import Link from '@/components/AppLink'; import { format } from 'date-fns'; import { Transaction } from '@/lib/types'; import { StatusBadge } from '@/components/StatusBadge'; @@ -9,7 +9,8 @@ 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 { getExecutionResult, getConsensusRoundResult } from '@/lib/transactionUtils'; +import { ConsensusResultBadge } from '@/components/ConsensusResultBadge'; import { resultStatusLabel, type DecodedResult } from '@/lib/resultDecoder'; import { InputDataPanel } from '@/components/InputDataPanel'; import { formatGenValue } from '@/lib/formatters'; @@ -93,6 +94,7 @@ export function OverviewTab({ transaction: tx }: OverviewTabProps) { const execResult = getExecutionResult(tx); const executionResult = execResult?.executionResult; const genvmResult = execResult?.genvmResult; + const consensusRound = getConsensusRoundResult(tx); const decodedResult = execResult?.decodedResult; const eqOutputs = execResult?.eqOutputs; @@ -154,6 +156,10 @@ export function OverviewTab({ transaction: tx }: OverviewTabProps) { /> + : '-'} + /> {tx.worker_id && } {calldataB64 && ( diff --git a/explorer/src/app/transactions/[hash]/components/RelatedTab.tsx b/explorer/src/app/transactions/[hash]/components/RelatedTab.tsx index a24bca0e8..8b204b357 100644 --- a/explorer/src/app/transactions/[hash]/components/RelatedTab.tsx +++ b/explorer/src/app/transactions/[hash]/components/RelatedTab.tsx @@ -1,6 +1,6 @@ 'use client'; -import Link from 'next/link'; +import Link from '@/components/AppLink'; import { format } from 'date-fns'; import { Transaction } from '@/lib/types'; import { StatusBadge } from '@/components/StatusBadge'; diff --git a/explorer/src/app/transactions/[hash]/page.tsx b/explorer/src/app/transactions/[hash]/page.tsx index 7b0e04e2f..b4fd97494 100644 --- a/explorer/src/app/transactions/[hash]/page.tsx +++ b/explorer/src/app/transactions/[hash]/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect, useState, useCallback, use } from 'react'; -import Link from 'next/link'; +import Link from '@/components/AppLink'; import { Transaction } from '@/lib/types'; import { useTransactionPolling } from '@/hooks/useTransactionPolling'; import { StatusBadge } from '@/components/StatusBadge'; diff --git a/explorer/src/app/transactions/page.tsx b/explorer/src/app/transactions/page.tsx index 6d14be597..68c953e86 100644 --- a/explorer/src/app/transactions/page.tsx +++ b/explorer/src/app/transactions/page.tsx @@ -37,6 +37,7 @@ function TransactionsContent() { const search = searchParams.get('search') || ''; const fromDate = searchParams.get('from_date') || ''; const toDate = searchParams.get('to_date') || ''; + const addressFilter = searchParams.get('address') || ''; // Derive comma-separated statuses from the active tab const activeTab = TRANSACTION_TABS.find(t => t.id === tab) || TRANSACTION_TABS[0]; @@ -53,6 +54,7 @@ function TransactionsContent() { if (search) params.set('search', search); if (fromDate) params.set('from_date', fromDate); if (toDate) params.set('to_date', toDate); + if (addressFilter) params.set('address', addressFilter); const res = await fetch(`/api/transactions?${params.toString()}`); if (!res.ok) throw new Error('Failed to fetch transactions'); @@ -63,7 +65,7 @@ function TransactionsContent() { } finally { setLoading(false); } - }, [page, limit, statusFilter, search, fromDate, toDate]); + }, [page, limit, statusFilter, search, fromDate, toDate, addressFilter]); useEffect(() => { fetchTransactions(); @@ -97,7 +99,11 @@ function TransactionsContent() {

Transactions

-

Browse and search all transactions

+

+ {addressFilter + ? <>Transactions for address {addressFilter} + : 'Browse and search all transactions'} +

updateParams({ tab: value === 'all' ? null : value, page: '1' })}> @@ -138,13 +144,13 @@ function TransactionsContent() { />
- {(tab !== 'all' || search || fromDate || toDate) && ( + {(tab !== 'all' || search || fromDate || toDate || addressFilter) && (