diff --git a/.env.example b/.env.example index 7706e62ba..99f47198d 100644 --- a/.env.example +++ b/.env.example @@ -83,6 +83,13 @@ OPENROUTERAPIKEY='' # If you want to use IO.net, add your key here IONETAPIKEY='' +######################################## +# Admin Access Control +# Set this to protect admin endpoints (explorer admin, validator management, etc.) +# When set, requests must include this key via X-Admin-Key header or admin_key param +# To rotate: change the value and restart the service +ADMIN_API_KEY='' + ######################################## # API Rate Limiting Configuration RATE_LIMIT_ENABLED='false' # Enable/disable API key rate limiting diff --git a/backend/protocol_rpc/dependencies.py b/backend/protocol_rpc/dependencies.py index 3ed09cd9f..5211c3744 100644 --- a/backend/protocol_rpc/dependencies.py +++ b/backend/protocol_rpc/dependencies.py @@ -198,3 +198,35 @@ def get_llm_provider_registry( def get_rate_limiter(request: Request): return _peek_state_attr(_get_app_state(request), "rate_limiter") + + +def require_admin_key(request: Request) -> None: + """FastAPI dependency that enforces ADMIN_API_KEY for explorer admin routes. + + Same logic as the JSON-RPC ``require_admin_access`` decorator: + - ADMIN_API_KEY set -> requires matching ``X-Admin-Key`` header + - VITE_IS_HOSTED=true without ADMIN_API_KEY -> blocked entirely + - Neither set -> open access (local dev) + """ + import os + + admin_api_key = os.getenv("ADMIN_API_KEY") + is_hosted = os.getenv("VITE_IS_HOSTED") == "true" + + if admin_api_key: + request_key = request.headers.get("X-Admin-Key") + if request_key == admin_api_key: + return + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or missing admin key", + ) + + if is_hosted: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Operation not available in hosted mode", + ) + + # Local dev = open access + return diff --git a/backend/protocol_rpc/explorer/admin_router.py b/backend/protocol_rpc/explorer/admin_router.py new file mode 100644 index 000000000..5b8c56a0e --- /dev/null +++ b/backend/protocol_rpc/explorer/admin_router.py @@ -0,0 +1,17 @@ +"""FastAPI router for explorer admin endpoints (protected by ADMIN_API_KEY).""" + +from fastapi import APIRouter, Depends + +from backend.protocol_rpc.dependencies import require_admin_key + +explorer_admin_router = APIRouter( + prefix="/api/explorer/admin", + tags=["explorer-admin"], + dependencies=[Depends(require_admin_key)], +) + + +@explorer_admin_router.get("/verify") +def verify_admin(): + """Health-check endpoint to verify admin access.""" + return {"status": "ok", "admin": True} diff --git a/backend/protocol_rpc/explorer/queries.py b/backend/protocol_rpc/explorer/queries.py index 37d0f18ab..483ec3b2d 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_all 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,158 @@ def get_all_states( .distinct() .subquery() ) + base_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, then combine. + 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() + ) + + tx_count_col = func.coalesce(to_stats.c.cnt, 0) + func.coalesce( + from_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) + .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)) + 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), + } - 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))) + # 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)) + 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). + """ + to_q = ( + session.query( + Transactions.to_address.label("addr"), + func.count().label("cnt"), + func.min(Transactions.created_at).label("min_ts"), + ) + .filter(Transactions.to_address.in_(contract_ids)) + .group_by(Transactions.to_address) + ) + from_q = ( + session.query( + Transactions.from_address.label("addr"), + func.count().label("cnt"), + func.min(Transactions.created_at).label("min_ts"), + ) + .filter(Transactions.from_address.in_(contract_ids)) + .group_by(Transactions.from_address) + ) + + combined = union_all(to_q, from_q).subquery() + rows = ( + session.query( + combined.c.addr, + func.sum(combined.c.cnt).label("tx_count"), + func.min(combined.c.min_ts).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 +620,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 +665,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 +762,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/protocol_rpc/fastapi_server.py b/backend/protocol_rpc/fastapi_server.py index 20e0b67c7..9aefc5094 100644 --- a/backend/protocol_rpc/fastapi_server.py +++ b/backend/protocol_rpc/fastapi_server.py @@ -20,6 +20,7 @@ ) from backend.protocol_rpc.fastapi_rpc_router import FastAPIRPCRouter from backend.protocol_rpc.explorer.router import explorer_router +from backend.protocol_rpc.explorer.admin_router import explorer_admin_router from backend.protocol_rpc.health import health_router from backend.protocol_rpc.rate_limit_middleware import RateLimitMiddleware from backend.protocol_rpc.rpc_endpoint_manager import JSONRPCResponse @@ -78,6 +79,7 @@ async def lifespan(app: FastAPI): # Include explorer API endpoints app.include_router(explorer_router) +app.include_router(explorer_admin_router) # JSON-RPC endpoint (supports single and batch requests) 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..16745e400 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'; 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) && (