Compare commits

..

6 Commits

Author SHA1 Message Date
defiQUG
39d2f2482b Guard block detail fetch state 2026-04-16 15:46:48 -07:00
defiQUG
06070e479e Guard block transaction fetch state 2026-04-16 15:06:26 -07:00
defiQUG
c01e7c2c4a Polish block transaction drilldown cleanup 2026-04-16 14:57:12 -07:00
defiQUG
945864af7a Refactor block transaction drilldown state 2026-04-16 14:48:30 -07:00
defiQUG
2e59b9d19c Tighten block transaction drilldown paging 2026-04-16 14:27:44 -07:00
defiQUG
b6e74eb5bd Add deterministic block transaction drilldown 2026-04-16 14:21:34 -07:00
11 changed files with 353 additions and 413 deletions

View File

@@ -1,92 +0,0 @@
package rest
import (
"encoding/json"
"errors"
"net/http"
"github.com/explorer/backend/auth"
)
// handleAuthRefresh implements POST /api/v1/auth/refresh.
//
// Contract:
// - Requires a valid, unrevoked wallet JWT in the Authorization header.
// - Mints a new JWT for the same address+track with a fresh jti and a
// fresh per-track TTL.
// - Revokes the presented token so it cannot be reused.
//
// This is the mechanism that makes the short Track-4 TTL (60 min in
// PR #8) acceptable: operators refresh while the token is still live
// rather than re-signing a SIWE message every hour.
func (s *Server) handleAuthRefresh(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
return
}
if s.walletAuth == nil {
writeError(w, http.StatusServiceUnavailable, "service_unavailable", "wallet auth not configured")
return
}
token := extractBearerToken(r)
if token == "" {
writeError(w, http.StatusUnauthorized, "unauthorized", "missing or malformed Authorization header")
return
}
resp, err := s.walletAuth.RefreshJWT(r.Context(), token)
if err != nil {
switch {
case errors.Is(err, auth.ErrJWTRevoked):
writeError(w, http.StatusUnauthorized, "token_revoked", err.Error())
case errors.Is(err, auth.ErrWalletAuthStorageNotInitialized):
writeError(w, http.StatusServiceUnavailable, "service_unavailable", err.Error())
default:
writeError(w, http.StatusUnauthorized, "unauthorized", err.Error())
}
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
// handleAuthLogout implements POST /api/v1/auth/logout.
//
// Records the presented token's jti in jwt_revocations so subsequent
// calls to ValidateJWT will reject it. Idempotent: logging out twice
// with the same token succeeds.
func (s *Server) handleAuthLogout(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
return
}
if s.walletAuth == nil {
writeError(w, http.StatusServiceUnavailable, "service_unavailable", "wallet auth not configured")
return
}
token := extractBearerToken(r)
if token == "" {
writeError(w, http.StatusUnauthorized, "unauthorized", "missing or malformed Authorization header")
return
}
if err := s.walletAuth.RevokeJWT(r.Context(), token, "logout"); err != nil {
switch {
case errors.Is(err, auth.ErrJWTRevocationStorageMissing):
// Surface 503 so ops know migration 0016 hasn't run; the
// client should treat the token as logged out locally.
writeError(w, http.StatusServiceUnavailable, "service_unavailable", err.Error())
default:
writeError(w, http.StatusUnauthorized, "unauthorized", err.Error())
}
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
})
}

View File

@@ -475,12 +475,8 @@ func (s *Server) HandleMissionControlBridgeTrace(w http.ResponseWriter, r *http.
body, statusCode, err := fetchBlockscoutTransaction(r.Context(), tx)
if err == nil && statusCode == http.StatusOK {
var txDoc map[string]interface{}
if uerr := json.Unmarshal(body, &txDoc); uerr != nil {
// Fall through to the RPC fallback below. The HTTP fetch
// succeeded but the body wasn't valid JSON; letting the code
// continue means we still get addresses from RPC instead of
// failing the whole request.
_ = uerr
if err := json.Unmarshal(body, &txDoc); err != nil {
err = fmt.Errorf("invalid blockscout JSON")
} else {
fromAddr = extractEthAddress(txDoc["from"])
toAddr = extractEthAddress(txDoc["to"])

View File

@@ -52,8 +52,6 @@ func (s *Server) SetupRoutes(mux *http.ServeMux) {
// Auth endpoints
mux.HandleFunc("/api/v1/auth/nonce", s.handleAuthNonce)
mux.HandleFunc("/api/v1/auth/wallet", s.handleAuthWallet)
mux.HandleFunc("/api/v1/auth/refresh", s.handleAuthRefresh)
mux.HandleFunc("/api/v1/auth/logout", s.handleAuthLogout)
mux.HandleFunc("/api/v1/auth/register", s.handleAuthRegister)
mux.HandleFunc("/api/v1/auth/login", s.handleAuthLogin)
mux.HandleFunc("/api/v1/access/me", s.handleAccessMe)

View File

@@ -21,49 +21,8 @@ var (
ErrWalletNonceNotFoundOrExpired = errors.New("nonce not found or expired")
ErrWalletNonceExpired = errors.New("nonce expired")
ErrWalletNonceInvalid = errors.New("invalid nonce")
ErrJWTRevoked = errors.New("token has been revoked")
ErrJWTRevocationStorageMissing = errors.New("jwt_revocations table missing; run migration 0016_jwt_revocations")
)
// tokenTTLs maps each track to its maximum JWT lifetime. Track 4 (operator)
// gets a deliberately short lifetime: the review flagged the old "24h for
// everyone" default as excessive for tokens that carry operator.write.*
// permissions. Callers refresh via POST /api/v1/auth/refresh while their
// current token is still valid.
var tokenTTLs = map[int]time.Duration{
1: 12 * time.Hour,
2: 8 * time.Hour,
3: 4 * time.Hour,
4: 60 * time.Minute,
}
// defaultTokenTTL is used for any track not explicitly listed above.
const defaultTokenTTL = 12 * time.Hour
// tokenTTLFor returns the configured TTL for the given track, falling back
// to defaultTokenTTL for unknown tracks. Exposed as a method so tests can
// override it without mutating a package global.
func tokenTTLFor(track int) time.Duration {
if ttl, ok := tokenTTLs[track]; ok {
return ttl
}
return defaultTokenTTL
}
func isMissingJWTRevocationTableError(err error) bool {
return err != nil && strings.Contains(err.Error(), `relation "jwt_revocations" does not exist`)
}
// newJTI returns a random JWT ID used for revocation tracking. 16 random
// bytes = 128 bits of entropy, hex-encoded.
func newJTI() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("generate jti: %w", err)
}
return hex.EncodeToString(b), nil
}
// WalletAuth handles wallet-based authentication
type WalletAuth struct {
db *pgxpool.Pool
@@ -248,20 +207,13 @@ func (w *WalletAuth) getUserTrack(ctx context.Context, address string) (int, err
return 1, nil
}
// generateJWT generates a JWT token with track, jti, exp, and iat claims.
// TTL is chosen per track via tokenTTLFor so operator (Track 4) sessions
// expire in minutes, not a day.
// generateJWT generates a JWT token with track claim
func (w *WalletAuth) generateJWT(address string, track int) (string, time.Time, error) {
jti, err := newJTI()
if err != nil {
return "", time.Time{}, err
}
expiresAt := time.Now().Add(tokenTTLFor(track))
expiresAt := time.Now().Add(24 * time.Hour)
claims := jwt.MapClaims{
"address": address,
"track": track,
"jti": jti,
"exp": expiresAt.Unix(),
"iat": time.Now().Unix(),
}
@@ -275,182 +227,55 @@ func (w *WalletAuth) generateJWT(address string, track int) (string, time.Time,
return tokenString, expiresAt, nil
}
// ValidateJWT validates a JWT token and returns the address and track.
// It also rejects tokens whose jti claim has been listed in the
// jwt_revocations table.
// ValidateJWT validates a JWT token and returns the address and track
func (w *WalletAuth) ValidateJWT(tokenString string) (string, int, error) {
address, track, _, _, err := w.parseJWT(tokenString)
if err != nil {
return "", 0, err
}
// If we have a database, enforce revocation and re-resolve the track
// (an operator revoking a wallet's Track 4 approval should not wait
// for the token to expire before losing the elevated permission).
if w.db != nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
jti, _ := w.jtiFromToken(tokenString)
if jti != "" {
revoked, revErr := w.isJTIRevoked(ctx, jti)
if revErr != nil && !errors.Is(revErr, ErrJWTRevocationStorageMissing) {
return "", 0, fmt.Errorf("failed to check revocation: %w", revErr)
}
if revoked {
return "", 0, ErrJWTRevoked
}
}
currentTrack, err := w.getUserTrack(ctx, address)
if err != nil {
return "", 0, fmt.Errorf("failed to resolve current track: %w", err)
}
if currentTrack < track {
track = currentTrack
}
}
return address, track, nil
}
// parseJWT performs signature verification and claim extraction without
// any database round-trip. Shared between ValidateJWT and RefreshJWT.
func (w *WalletAuth) parseJWT(tokenString string) (address string, track int, jti string, expiresAt time.Time, err error) {
token, perr := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return w.jwtSecret, nil
})
if perr != nil {
return "", 0, "", time.Time{}, fmt.Errorf("failed to parse token: %w", perr)
if err != nil {
return "", 0, fmt.Errorf("failed to parse token: %w", err)
}
if !token.Valid {
return "", 0, "", time.Time{}, fmt.Errorf("invalid token")
return "", 0, fmt.Errorf("invalid token")
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return "", 0, "", time.Time{}, fmt.Errorf("invalid token claims")
return "", 0, fmt.Errorf("invalid token claims")
}
address, ok = claims["address"].(string)
address, ok := claims["address"].(string)
if !ok {
return "", 0, "", time.Time{}, fmt.Errorf("address not found in token")
return "", 0, fmt.Errorf("address not found in token")
}
trackFloat, ok := claims["track"].(float64)
if !ok {
return "", 0, "", time.Time{}, fmt.Errorf("track not found in token")
return "", 0, fmt.Errorf("track not found in token")
}
track = int(trackFloat)
if v, ok := claims["jti"].(string); ok {
jti = v
}
if expFloat, ok := claims["exp"].(float64); ok {
expiresAt = time.Unix(int64(expFloat), 0)
}
return address, track, jti, expiresAt, nil
}
// jtiFromToken parses the jti claim without doing a fresh signature check.
// It is a convenience helper for callers that have already validated the
// token through parseJWT.
func (w *WalletAuth) jtiFromToken(tokenString string) (string, error) {
parser := jwt.Parser{}
token, _, err := parser.ParseUnverified(tokenString, jwt.MapClaims{})
if err != nil {
return "", err
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return "", fmt.Errorf("invalid claims")
}
v, _ := claims["jti"].(string)
return v, nil
}
// isJTIRevoked checks whether the given jti appears in jwt_revocations.
// Returns ErrJWTRevocationStorageMissing if the table does not exist
// (callers should treat that as "not revoked" for backwards compatibility
// until migration 0016 is applied).
func (w *WalletAuth) isJTIRevoked(ctx context.Context, jti string) (bool, error) {
var exists bool
err := w.db.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM jwt_revocations WHERE jti = $1)`, jti,
).Scan(&exists)
if err != nil {
if isMissingJWTRevocationTableError(err) {
return false, ErrJWTRevocationStorageMissing
}
return false, err
}
return exists, nil
}
// RevokeJWT records the token's jti in jwt_revocations. Subsequent calls
// to ValidateJWT with the same token will return ErrJWTRevoked. Idempotent
// on duplicate jti.
func (w *WalletAuth) RevokeJWT(ctx context.Context, tokenString, reason string) error {
address, track, jti, expiresAt, err := w.parseJWT(tokenString)
if err != nil {
return err
}
if jti == "" {
// Legacy tokens issued before PR #8 don't carry a jti; there is
// nothing to revoke server-side. Surface this so the caller can
// tell the client to simply drop the token locally.
return fmt.Errorf("token has no jti claim (legacy token — client should discard locally)")
}
track := int(trackFloat)
if w.db == nil {
return fmt.Errorf("wallet auth has no database; cannot revoke")
}
if strings.TrimSpace(reason) == "" {
reason = "logout"
}
_, err = w.db.Exec(ctx,
`INSERT INTO jwt_revocations (jti, address, track, token_expires_at, reason)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (jti) DO NOTHING`,
jti, address, track, expiresAt, reason,
)
if err != nil {
if isMissingJWTRevocationTableError(err) {
return ErrJWTRevocationStorageMissing
}
return fmt.Errorf("record revocation: %w", err)
}
return nil
}
// RefreshJWT issues a new token for the same address+track if the current
// token is valid (signed, unexpired, not revoked) and revokes the current
// token so it cannot be replayed. Returns the new token and its exp.
func (w *WalletAuth) RefreshJWT(ctx context.Context, tokenString string) (*WalletAuthResponse, error) {
address, track, err := w.ValidateJWT(tokenString)
if err != nil {
return nil, err
}
// Revoke the old token before issuing a new one. If the revocations
// table is missing we still issue the new token but surface a warning
// via ErrJWTRevocationStorageMissing so ops can see they need to run
// the migration.
var revokeErr error
if w.db != nil {
revokeErr = w.RevokeJWT(ctx, tokenString, "refresh")
if revokeErr != nil && !errors.Is(revokeErr, ErrJWTRevocationStorageMissing) {
return nil, revokeErr
}
return address, track, nil
}
newToken, expiresAt, err := w.generateJWT(address, track)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
currentTrack, err := w.getUserTrack(ctx, address)
if err != nil {
return nil, err
return "", 0, fmt.Errorf("failed to resolve current track: %w", err)
}
return &WalletAuthResponse{
Token: newToken,
ExpiresAt: expiresAt,
Track: track,
Permissions: getPermissionsForTrack(track),
}, revokeErr
if currentTrack < track {
track = currentTrack
}
return address, track, nil
}
func decodeWalletSignature(signature string) ([]byte, error) {

View File

@@ -1,9 +1,7 @@
package auth
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
)
@@ -28,59 +26,3 @@ func TestValidateJWTReturnsClaimsWhenDBUnavailable(t *testing.T) {
require.Equal(t, "0x4A666F96fC8764181194447A7dFdb7d471b301C8", address)
require.Equal(t, 4, track)
}
func TestTokenTTLForTrack4IsShort(t *testing.T) {
// Track 4 (operator) must have a TTL <= 1h — that is the headline
// tightening promised by completion criterion 3 (JWT hygiene).
ttl := tokenTTLFor(4)
require.LessOrEqual(t, ttl, time.Hour, "track 4 TTL must be <= 1h")
require.Greater(t, ttl, time.Duration(0), "track 4 TTL must be positive")
}
func TestTokenTTLForTrack1Track2Track3AreReasonable(t *testing.T) {
// Non-operator tracks are allowed longer sessions, but still bounded
// at 12h so a stale laptop tab doesn't carry a week-old token.
for _, track := range []int{1, 2, 3} {
ttl := tokenTTLFor(track)
require.Greater(t, ttl, time.Duration(0), "track %d TTL must be > 0", track)
require.LessOrEqual(t, ttl, 12*time.Hour, "track %d TTL must be <= 12h", track)
}
}
func TestGeneratedJWTCarriesJTIClaim(t *testing.T) {
// Revocation keys on jti. A token issued without one is unrevokable
// and must not be produced.
a := NewWalletAuth(nil, []byte("test-secret"))
token, _, err := a.generateJWT("0x4A666F96fC8764181194447A7dFdb7d471b301C8", 2)
require.NoError(t, err)
jti, err := a.jtiFromToken(token)
require.NoError(t, err)
require.NotEmpty(t, jti, "generated JWT must carry a jti claim")
require.Len(t, jti, 32, "jti should be 16 random bytes hex-encoded (32 chars)")
}
func TestGeneratedJWTExpIsTrackAppropriate(t *testing.T) {
a := NewWalletAuth(nil, []byte("test-secret"))
for _, track := range []int{1, 2, 3, 4} {
_, expiresAt, err := a.generateJWT("0x4A666F96fC8764181194447A7dFdb7d471b301C8", track)
require.NoError(t, err)
want := tokenTTLFor(track)
// allow a couple-second slack for test execution
actual := time.Until(expiresAt)
require.InDelta(t, want.Seconds(), actual.Seconds(), 5.0,
"track %d exp should be ~%s from now, got %s", track, want, actual)
}
}
func TestRevokeJWTWithoutDBReturnsError(t *testing.T) {
// With w.db == nil, revocation has nowhere to write — the call must
// fail loudly so callers don't silently assume a token was revoked.
a := NewWalletAuth(nil, []byte("test-secret"))
token, _, err := a.generateJWT("0x4A666F96fC8764181194447A7dFdb7d471b301C8", 4)
require.NoError(t, err)
err = a.RevokeJWT(context.Background(), token, "test")
require.Error(t, err)
require.Contains(t, err.Error(), "no database")
}

View File

@@ -1,4 +0,0 @@
-- Migration 0016_jwt_revocations.down.sql
DROP INDEX IF EXISTS idx_jwt_revocations_expires;
DROP INDEX IF EXISTS idx_jwt_revocations_address;
DROP TABLE IF EXISTS jwt_revocations;

View File

@@ -1,30 +0,0 @@
-- Migration 0016_jwt_revocations.up.sql
--
-- Introduces server-side JWT revocation for the SolaceScan backend.
--
-- Up to this migration, tokens issued by /api/v1/auth/wallet were simply
-- signed and returned; the backend had no way to invalidate a token before
-- its exp claim short of rotating the JWT_SECRET (which would invalidate
-- every outstanding session). PR #8 introduces per-token revocation keyed
-- on the `jti` claim.
--
-- The table is append-only: a row exists iff that jti has been revoked.
-- ValidateJWT consults the table on every request; the primary key on
-- (jti) keeps lookups O(log n) and deduplicates repeated logout calls.
CREATE TABLE IF NOT EXISTS jwt_revocations (
jti TEXT PRIMARY KEY,
address TEXT NOT NULL,
track INT NOT NULL,
-- original exp of the revoked token, so a background janitor can
-- reap rows after they can no longer matter.
token_expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
reason TEXT NOT NULL DEFAULT 'logout'
);
CREATE INDEX IF NOT EXISTS idx_jwt_revocations_address
ON jwt_revocations (address);
CREATE INDEX IF NOT EXISTS idx_jwt_revocations_expires
ON jwt_revocations (token_expires_at);

View File

@@ -0,0 +1,77 @@
import { useEffect, useRef, useState } from 'react'
import { transactionsApi, type Transaction } from '@/services/api/transactions'
const DEFAULT_BLOCK_TRANSACTION_PAGE_SIZE = 25
interface UseBlockTransactionsOptions {
blockNumber: number
chainId: number
enabled: boolean
}
export function useBlockTransactions({ blockNumber, chainId, enabled }: UseBlockTransactionsOptions) {
const [transactions, setTransactions] = useState<Transaction[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
const [hasNextPage, setHasNextPage] = useState(false)
const [page, setPage] = useState(1)
const previousBlockNumberRef = useRef(blockNumber)
useEffect(() => {
if (!enabled) {
previousBlockNumberRef.current = blockNumber
if (page !== 1) {
setPage(1)
}
setTransactions([])
setLoading(false)
setError(false)
setHasNextPage(false)
return
}
if (previousBlockNumberRef.current !== blockNumber) {
previousBlockNumberRef.current = blockNumber
if (page !== 1) {
setPage(1)
return
}
}
let cancelled = false
setLoading(true)
setError(false)
void (async () => {
const result = await transactionsApi.listByBlockSafe(
chainId,
blockNumber,
page,
DEFAULT_BLOCK_TRANSACTION_PAGE_SIZE,
)
if (cancelled) {
return
}
setTransactions(result.items)
setHasNextPage(result.hasNextPage)
setError(!result.ok)
setLoading(false)
})()
return () => {
cancelled = true
}
}, [blockNumber, chainId, enabled, page])
return {
transactions,
loading,
error,
hasNextPage,
page,
setPage,
}
}

View File

@@ -1,13 +1,15 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/router'
import { blocksApi, Block } from '@/services/api/blocks'
import { Card, Address } from '@/libs/frontend-ui-primitives'
import { Card, Address, Table } from '@/libs/frontend-ui-primitives'
import Link from 'next/link'
import { DetailRow } from '@/components/common/DetailRow'
import PageIntro from '@/components/common/PageIntro'
import { formatTimestamp } from '@/utils/format'
import { formatTimestamp, formatWeiAsEth } from '@/utils/format'
import { type Transaction } from '@/services/api/transactions'
import { useBlockTransactions } from '@/hooks/useBlockTransactions'
export default function BlockDetailPage() {
const router = useRouter()
@@ -19,17 +21,18 @@ export default function BlockDetailPage() {
const [block, setBlock] = useState<Block | null>(null)
const [loading, setLoading] = useState(true)
const loadBlock = useCallback(async () => {
setLoading(true)
try {
const response = await blocksApi.getByNumber(chainId, blockNumber)
setBlock(response.data)
} catch (error) {
console.error('Failed to load block:', error)
} finally {
setLoading(false)
}
}, [chainId, blockNumber])
const {
transactions: blockTransactions,
loading: transactionsLoading,
error: transactionsError,
hasNextPage: hasNextTransactionsPage,
page: transactionPage,
setPage: setTransactionPage,
} = useBlockTransactions({
blockNumber,
chainId,
enabled: router.isReady && isValidBlock,
})
useEffect(() => {
if (!router.isReady) {
@@ -40,12 +43,85 @@ export default function BlockDetailPage() {
setBlock(null)
return
}
loadBlock()
}, [isValidBlock, loadBlock, router.isReady])
let cancelled = false
setLoading(true)
void (async () => {
try {
const response = await blocksApi.getByNumber(chainId, blockNumber)
if (cancelled) {
return
}
setBlock(response.data)
} catch (error) {
console.error('Failed to load block:', error)
if (cancelled) {
return
}
setBlock(null)
} finally {
if (!cancelled) {
setLoading(false)
}
}
})()
return () => {
cancelled = true
}
}, [blockNumber, chainId, isValidBlock, router.isReady])
const gasUtilization = block && block.gas_limit > 0
? Math.round((block.gas_used / block.gas_limit) * 100)
: null
const transactionColumns = useMemo(() => [
{
header: 'Hash',
accessor: (transaction: Transaction) => (
<Link href={`/transactions/${transaction.hash}`} className="text-primary-600 hover:underline">
<Address address={transaction.hash} truncate showCopy={false} />
</Link>
),
},
{
header: 'From',
accessor: (transaction: Transaction) => (
<Link href={`/addresses/${transaction.from_address}`} className="text-primary-600 hover:underline">
<Address address={transaction.from_address} truncate showCopy={false} />
</Link>
),
},
{
header: 'To',
accessor: (transaction: Transaction) =>
transaction.to_address ? (
<Link href={`/addresses/${transaction.to_address}`} className="text-primary-600 hover:underline">
<Address address={transaction.to_address} truncate showCopy={false} />
</Link>
) : (
<span className="text-gray-500 dark:text-gray-400">Contract creation</span>
),
},
{
header: 'Value',
accessor: (transaction: Transaction) => formatWeiAsEth(transaction.value),
},
{
header: 'Status',
accessor: (transaction: Transaction) => (
<span className={transaction.status === 1 ? 'text-green-600' : 'text-red-600'}>
{transaction.status === 1 ? 'Success' : 'Failed'}
</span>
),
},
], [])
const transactionsEmptyMessage = transactionsError
? 'Unable to load indexed block transactions right now. Please retry from this page in a moment.'
: (block?.transaction_count ?? 0) > 0
? 'No indexed block transactions were returned for this page yet.'
: 'This block does not contain any indexed transactions.'
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
@@ -74,6 +150,11 @@ export default function BlockDetailPage() {
Next block
</Link>
)}
{block?.transaction_count ? (
<a href="#block-transactions" className="text-primary-600 hover:underline">
Open block transactions
</a>
) : null}
</div>
{!router.isReady || loading ? (
@@ -119,9 +200,9 @@ export default function BlockDetailPage() {
</Link>
</DetailRow>
<DetailRow label="Transactions">
<Link href={`/search?q=${block.number}`} className="text-primary-600 hover:underline">
<a href="#block-transactions" className="text-primary-600 hover:underline">
{block.transaction_count}
</Link>
</a>
</DetailRow>
<DetailRow label="Gas Used">
{block.gas_used.toLocaleString()} / {block.gas_limit.toLocaleString()}
@@ -134,6 +215,53 @@ export default function BlockDetailPage() {
</dl>
</Card>
)}
{block && (
<Card title="Block Transactions" className="mt-6">
<div id="block-transactions" className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
This section shows the exact indexed transaction set for block #{block.number.toLocaleString()}, independent of generic explorer search.
</p>
{transactionsLoading ? (
<p className="text-sm text-gray-600 dark:text-gray-400">Loading block transactions...</p>
) : (
<>
<Table
columns={transactionColumns}
data={blockTransactions}
emptyMessage={transactionsEmptyMessage}
keyExtractor={(transaction) => transaction.hash}
/>
{block.transaction_count > 0 ? (
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-gray-600 dark:text-gray-400">
<span>
Showing page {transactionPage} of the indexed transactions for this block.
</span>
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={() => setTransactionPage((current) => Math.max(1, current - 1))}
disabled={transactionsLoading || transactionPage === 1}
className="rounded bg-gray-200 px-4 py-2 text-gray-900 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-gray-800 dark:text-gray-100"
>
Previous tx page
</button>
<button
type="button"
onClick={() => setTransactionPage((current) => current + 1)}
disabled={transactionsLoading || !hasNextTransactionsPage}
className="rounded bg-gray-200 px-4 py-2 text-gray-900 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-gray-800 dark:text-gray-100"
>
Next tx page
</button>
</div>
</div>
) : null}
</>
)}
</div>
</Card>
)}
</div>
)
}

View File

@@ -2,6 +2,62 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { transactionsApi } from './transactions'
describe('transactionsApi.listByBlockSafe', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('returns normalized transactions for a specific block', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
items: [
{
hash: '0xabc',
block_number: 123,
block_hash: '0xdef',
transaction_index: 0,
from: { hash: '0x0000000000000000000000000000000000000001' },
to: { hash: '0x0000000000000000000000000000000000000002' },
value: '0',
gas_price: '1',
gas: '21000',
gas_used: '21000',
status: 'ok',
timestamp: '2026-04-16T09:40:12.000000Z',
},
],
next_page_params: { page: 2, page_size: 10 },
}),
})
vi.stubGlobal('fetch', fetchMock)
const result = await transactionsApi.listByBlockSafe(138, 123, 1, 10)
expect(result.ok).toBe(true)
expect(result.items).toHaveLength(1)
expect(result.hasNextPage).toBe(true)
expect(result.items[0]?.hash).toBe('0xabc')
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(fetchMock.mock.calls[0]?.[0]).toEqual(
expect.stringContaining('/api/v2/blocks/123/transactions?page=1&page_size=10'),
)
})
it('returns a non-throwing failure result when the block transaction request fails', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')))
const result = await transactionsApi.listByBlockSafe(138, 123, 1, 10)
expect(result).toEqual({
ok: false,
items: [],
hasNextPage: false,
})
})
})
describe('transactionsApi.diagnoseMissing', () => {
beforeEach(() => {
vi.restoreAllMocks()

View File

@@ -76,6 +76,17 @@ export interface TransactionLookupDiagnostic {
rpc_url?: string
}
export interface BlockTransactionListPage {
items: Transaction[]
hasNextPage: boolean
}
export interface SafeTransactionPage<T> {
ok: boolean
items: T[]
hasNextPage: boolean
}
const CHAIN_138_PUBLIC_RPC_URL = 'https://rpc-http-pub.d-bis.org'
function resolvePublicRpcUrl(chainId: number): string | null {
@@ -227,4 +238,37 @@ export const transactionsApi = {
return { ok: false, data: [] }
}
},
listByBlock: async (chainId: number, blockNumber: number, page = 1, pageSize = 25): Promise<ApiResponse<BlockTransactionListPage>> => {
const params = new URLSearchParams({
page: page.toString(),
page_size: pageSize.toString(),
})
const raw = await fetchBlockscoutJson<{ items?: unknown[]; next_page_params?: Record<string, unknown> | null }>(
`/api/v2/blocks/${blockNumber}/transactions?${params.toString()}`
)
const data = Array.isArray(raw?.items) ? raw.items.map((item) => normalizeTransaction(item as never, chainId)) : []
return {
data: {
items: data,
hasNextPage: raw?.next_page_params != null,
},
}
},
listByBlockSafe: async (
chainId: number,
blockNumber: number,
page = 1,
pageSize = 25,
): Promise<SafeTransactionPage<Transaction>> => {
try {
const { data } = await transactionsApi.listByBlock(chainId, blockNumber, page, pageSize)
return {
ok: true,
items: data.items,
hasNextPage: data.hasNextPage,
}
} catch {
return { ok: false, items: [], hasNextPage: false }
}
},
}