Compare commits
1 Commits
feat/explo
...
devin/1776
| Author | SHA1 | Date | |
|---|---|---|---|
| 29fe704f3c |
92
backend/api/rest/auth_refresh.go
Normal file
92
backend/api/rest/auth_refresh.go
Normal file
@@ -0,0 +1,92 @@
|
||||
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",
|
||||
})
|
||||
}
|
||||
@@ -475,8 +475,12 @@ 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 err := json.Unmarshal(body, &txDoc); err != nil {
|
||||
err = fmt.Errorf("invalid blockscout JSON")
|
||||
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
|
||||
} else {
|
||||
fromAddr = extractEthAddress(txDoc["from"])
|
||||
toAddr = extractEthAddress(txDoc["to"])
|
||||
|
||||
@@ -52,6 +52,8 @@ 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)
|
||||
|
||||
@@ -21,8 +21,49 @@ 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
|
||||
@@ -207,13 +248,20 @@ func (w *WalletAuth) getUserTrack(ctx context.Context, address string) (int, err
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
// generateJWT generates a JWT token with track claim
|
||||
// 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.
|
||||
func (w *WalletAuth) generateJWT(address string, track int) (string, time.Time, error) {
|
||||
expiresAt := time.Now().Add(24 * time.Hour)
|
||||
jti, err := newJTI()
|
||||
if err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
expiresAt := time.Now().Add(tokenTTLFor(track))
|
||||
|
||||
claims := jwt.MapClaims{
|
||||
"address": address,
|
||||
"track": track,
|
||||
"jti": jti,
|
||||
"exp": expiresAt.Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
@@ -227,55 +275,182 @@ 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
|
||||
// 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.
|
||||
func (w *WalletAuth) ValidateJWT(tokenString string) (string, int, error) {
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, 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) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return w.jwtSecret, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("failed to parse token: %w", err)
|
||||
if perr != nil {
|
||||
return "", 0, "", time.Time{}, fmt.Errorf("failed to parse token: %w", perr)
|
||||
}
|
||||
|
||||
if !token.Valid {
|
||||
return "", 0, fmt.Errorf("invalid token")
|
||||
return "", 0, "", time.Time{}, fmt.Errorf("invalid token")
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return "", 0, fmt.Errorf("invalid token claims")
|
||||
return "", 0, "", time.Time{}, fmt.Errorf("invalid token claims")
|
||||
}
|
||||
|
||||
address, ok := claims["address"].(string)
|
||||
address, ok = claims["address"].(string)
|
||||
if !ok {
|
||||
return "", 0, fmt.Errorf("address not found in token")
|
||||
return "", 0, "", time.Time{}, fmt.Errorf("address not found in token")
|
||||
}
|
||||
|
||||
trackFloat, ok := claims["track"].(float64)
|
||||
if !ok {
|
||||
return "", 0, fmt.Errorf("track not found in token")
|
||||
return "", 0, "", time.Time{}, fmt.Errorf("track not found in token")
|
||||
}
|
||||
|
||||
track := int(trackFloat)
|
||||
if w.db == nil {
|
||||
return address, track, nil
|
||||
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
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
currentTrack, err := w.getUserTrack(ctx, address)
|
||||
// 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 "", 0, fmt.Errorf("failed to resolve current track: %w", err)
|
||||
return "", err
|
||||
}
|
||||
if currentTrack < track {
|
||||
track = currentTrack
|
||||
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)")
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &WalletAuthResponse{
|
||||
Token: newToken,
|
||||
ExpiresAt: expiresAt,
|
||||
Track: track,
|
||||
Permissions: getPermissionsForTrack(track),
|
||||
}, revokeErr
|
||||
}
|
||||
|
||||
func decodeWalletSignature(signature string) ([]byte, error) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -26,3 +28,59 @@ 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")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- 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;
|
||||
30
backend/database/migrations/0016_jwt_revocations.up.sql
Normal file
30
backend/database/migrations/0016_jwt_revocations.up.sql
Normal file
@@ -0,0 +1,30 @@
|
||||
-- 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);
|
||||
@@ -1,49 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, type ReactNode, useContext, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
export type DisplayCurrency = 'native' | 'usd'
|
||||
|
||||
const DISPLAY_CURRENCY_STORAGE_KEY = 'explorer_display_currency'
|
||||
|
||||
const DisplayCurrencyContext = createContext<{
|
||||
currency: DisplayCurrency
|
||||
setCurrency: (currency: DisplayCurrency) => void
|
||||
} | null>(null)
|
||||
|
||||
export function DisplayCurrencyProvider({ children }: { children: ReactNode }) {
|
||||
const [currency, setCurrencyState] = useState<DisplayCurrency>('native')
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
const stored = window.localStorage.getItem(DISPLAY_CURRENCY_STORAGE_KEY)
|
||||
if (stored === 'native' || stored === 'usd') {
|
||||
setCurrencyState(stored)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const setCurrency = (nextCurrency: DisplayCurrency) => {
|
||||
setCurrencyState(nextCurrency)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(DISPLAY_CURRENCY_STORAGE_KEY, nextCurrency)
|
||||
}
|
||||
}
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
currency,
|
||||
setCurrency,
|
||||
}),
|
||||
[currency],
|
||||
)
|
||||
|
||||
return <DisplayCurrencyContext.Provider value={value}>{children}</DisplayCurrencyContext.Provider>
|
||||
}
|
||||
|
||||
export function useDisplayCurrency() {
|
||||
const context = useContext(DisplayCurrencyContext)
|
||||
if (!context) {
|
||||
throw new Error('useDisplayCurrency must be used within a DisplayCurrencyProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -2,28 +2,25 @@ import type { ReactNode } from 'react'
|
||||
import Navbar from './Navbar'
|
||||
import Footer from './Footer'
|
||||
import ExplorerAgentTool from './ExplorerAgentTool'
|
||||
import { DisplayCurrencyProvider } from './DisplayCurrencyContext'
|
||||
import { UiModeProvider } from './UiModeContext'
|
||||
|
||||
export default function ExplorerChrome({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<UiModeProvider>
|
||||
<DisplayCurrencyProvider>
|
||||
<div className="flex min-h-screen flex-col bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100">
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-[100] focus:rounded-md focus:bg-primary-600 focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-white"
|
||||
>
|
||||
Skip to content
|
||||
</a>
|
||||
<Navbar />
|
||||
<div id="main-content" className="flex-1">
|
||||
{children}
|
||||
</div>
|
||||
<ExplorerAgentTool />
|
||||
<Footer />
|
||||
<div className="flex min-h-screen flex-col bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100">
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-[100] focus:rounded-md focus:bg-primary-600 focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-white"
|
||||
>
|
||||
Skip to content
|
||||
</a>
|
||||
<Navbar />
|
||||
<div id="main-content" className="flex-1">
|
||||
{children}
|
||||
</div>
|
||||
</DisplayCurrencyProvider>
|
||||
<ExplorerAgentTool />
|
||||
<Footer />
|
||||
</div>
|
||||
</UiModeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,10 +12,8 @@ import { DetailRow } from '@/components/common/DetailRow'
|
||||
import EntityBadge from '@/components/common/EntityBadge'
|
||||
import GruStandardsCard from '@/components/common/GruStandardsCard'
|
||||
import { formatTokenAmount, formatTimestamp } from '@/utils/format'
|
||||
import { useDisplayCurrency } from '@/components/common/DisplayCurrencyContext'
|
||||
import { getGruStandardsProfileSafe, type GruStandardsProfile } from '@/services/api/gru'
|
||||
import { getGruExplorerMetadata } from '@/services/api/gruExplorerData'
|
||||
import { formatUsdValue, getSecondaryDisplayValue } from '@/utils/displayCurrency'
|
||||
|
||||
function isValidAddress(value: string) {
|
||||
return /^0x[a-fA-F0-9]{40}$/.test(value)
|
||||
@@ -33,12 +31,15 @@ function toNumeric(value: string | number | null | undefined): number | null {
|
||||
function formatUsd(value: string | number | null | undefined): string {
|
||||
const numeric = toNumeric(value)
|
||||
if (numeric == null) return 'Unavailable'
|
||||
return formatUsdValue(numeric)
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: numeric >= 100 ? 0 : 2,
|
||||
}).format(numeric)
|
||||
}
|
||||
|
||||
export default function TokenDetailPage() {
|
||||
const router = useRouter()
|
||||
const { currency, setCurrency } = useDisplayCurrency()
|
||||
const address = typeof router.query.address === 'string' ? router.query.address : ''
|
||||
const isValidTokenAddress = address !== '' && isValidAddress(address)
|
||||
|
||||
@@ -176,28 +177,6 @@ export default function TokenDetailPage() {
|
||||
[address, token?.address, token?.symbol],
|
||||
)
|
||||
|
||||
const renderAmountWithDisplayCurrency = useCallback(
|
||||
(rawAmount: string | number | null | undefined, decimals: number, symbol?: string | null) => {
|
||||
const primaryAmount = formatTokenAmount(rawAmount, decimals, symbol)
|
||||
const secondaryAmount = getSecondaryDisplayValue({
|
||||
rawAmount,
|
||||
decimals,
|
||||
exchangeRate: token?.exchange_rate,
|
||||
displayCurrency: currency,
|
||||
})
|
||||
|
||||
if (!secondaryAmount) return primaryAmount
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div>{primaryAmount}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">Approx. {secondaryAmount}</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
[currency, token?.exchange_rate],
|
||||
)
|
||||
|
||||
const holderColumns = [
|
||||
{
|
||||
header: 'Holder',
|
||||
@@ -209,7 +188,7 @@ export default function TokenDetailPage() {
|
||||
},
|
||||
{
|
||||
header: 'Balance',
|
||||
accessor: (holder: TokenHolder) => renderAmountWithDisplayCurrency(holder.value, token?.decimals || holder.token_decimals, token?.symbol),
|
||||
accessor: (holder: TokenHolder) => formatTokenAmount(holder.value, token?.decimals || holder.token_decimals, token?.symbol),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -259,7 +238,7 @@ export default function TokenDetailPage() {
|
||||
},
|
||||
{
|
||||
header: 'Amount',
|
||||
accessor: (transfer: AddressTokenTransfer) => renderAmountWithDisplayCurrency(transfer.value, transfer.token_decimals, transfer.token_symbol),
|
||||
accessor: (transfer: AddressTokenTransfer) => formatTokenAmount(transfer.value, transfer.token_decimals, transfer.token_symbol),
|
||||
},
|
||||
{
|
||||
header: 'When',
|
||||
@@ -337,27 +316,9 @@ export default function TokenDetailPage() {
|
||||
</DetailRow>
|
||||
<DetailRow label="Type">{token.type || 'Unknown'}</DetailRow>
|
||||
<DetailRow label="Decimals">{token.decimals}</DetailRow>
|
||||
<DetailRow label="Display Currency">
|
||||
<div className="space-y-2">
|
||||
<label className="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<span className="sr-only">Display currency</span>
|
||||
<select
|
||||
value={currency}
|
||||
onChange={(event) => setCurrency(event.target.value === 'usd' ? 'usd' : 'native')}
|
||||
className="rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:focus:ring-primary-900"
|
||||
>
|
||||
<option value="native">Native token amounts</option>
|
||||
<option value="usd">USD estimate</option>
|
||||
</select>
|
||||
</label>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
USD estimates use the explorer's current indicative token price and appear as a secondary line when available.
|
||||
</div>
|
||||
</div>
|
||||
</DetailRow>
|
||||
{token.total_supply && (
|
||||
<DetailRow label="Total Supply">
|
||||
{renderAmountWithDisplayCurrency(token.total_supply, token.decimals, token.symbol)}
|
||||
{formatTokenAmount(token.total_supply, token.decimals, token.symbol)}
|
||||
</DetailRow>
|
||||
)}
|
||||
{token.holders != null && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Block } from '@/services/api/blocks'
|
||||
import type { ExplorerStats, ExplorerTransactionTrendPoint } from '@/services/api/stats'
|
||||
import type { ExplorerStats } from '@/services/api/stats'
|
||||
import { loadDashboardData } from './dashboard'
|
||||
|
||||
const sampleStats: ExplorerStats = {
|
||||
@@ -23,17 +23,6 @@ const sampleBlocks: Block[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const sampleTrend: ExplorerTransactionTrendPoint[] = [
|
||||
{
|
||||
date: '2026-04-03',
|
||||
count: 11,
|
||||
},
|
||||
{
|
||||
date: '2026-04-04',
|
||||
count: 17,
|
||||
},
|
||||
]
|
||||
|
||||
describe('loadDashboardData', () => {
|
||||
it('returns both stats and recent blocks when both loaders succeed', async () => {
|
||||
const result = await loadDashboardData({
|
||||
@@ -44,7 +33,6 @@ describe('loadDashboardData', () => {
|
||||
expect(result).toEqual({
|
||||
stats: sampleStats,
|
||||
recentBlocks: sampleBlocks,
|
||||
recentTransactionTrend: [],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -62,7 +50,6 @@ describe('loadDashboardData', () => {
|
||||
expect(result).toEqual({
|
||||
stats: null,
|
||||
recentBlocks: sampleBlocks,
|
||||
recentTransactionTrend: [],
|
||||
})
|
||||
expect(onError).toHaveBeenCalledTimes(1)
|
||||
expect(onError).toHaveBeenCalledWith('stats', expect.any(Error))
|
||||
@@ -82,44 +69,8 @@ describe('loadDashboardData', () => {
|
||||
expect(result).toEqual({
|
||||
stats: sampleStats,
|
||||
recentBlocks: [],
|
||||
recentTransactionTrend: [],
|
||||
})
|
||||
expect(onError).toHaveBeenCalledTimes(1)
|
||||
expect(onError).toHaveBeenCalledWith('blocks', expect.any(Error))
|
||||
})
|
||||
|
||||
it('returns the recent transaction trend when the optional loader succeeds', async () => {
|
||||
const result = await loadDashboardData({
|
||||
loadStats: async () => sampleStats,
|
||||
loadRecentBlocks: async () => sampleBlocks,
|
||||
loadRecentTransactionTrend: async () => sampleTrend,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
stats: sampleStats,
|
||||
recentBlocks: sampleBlocks,
|
||||
recentTransactionTrend: sampleTrend,
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to an empty recent transaction trend when the optional loader fails', async () => {
|
||||
const onError = vi.fn()
|
||||
|
||||
const result = await loadDashboardData({
|
||||
loadStats: async () => sampleStats,
|
||||
loadRecentBlocks: async () => sampleBlocks,
|
||||
loadRecentTransactionTrend: async () => {
|
||||
throw new Error('trend unavailable')
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
stats: sampleStats,
|
||||
recentBlocks: sampleBlocks,
|
||||
recentTransactionTrend: [],
|
||||
})
|
||||
expect(onError).toHaveBeenCalledTimes(1)
|
||||
expect(onError).toHaveBeenCalledWith('trend', expect.any(Error))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatUsdValue, getSecondaryDisplayValue } from './displayCurrency'
|
||||
|
||||
describe('formatUsdValue', () => {
|
||||
it('keeps cents for smaller values', () => {
|
||||
expect(formatUsdValue(4.5)).toBe('$4.50')
|
||||
})
|
||||
|
||||
it('drops cents for larger rounded values', () => {
|
||||
expect(formatUsdValue(1250)).toBe('$1,250')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSecondaryDisplayValue', () => {
|
||||
it('returns null when the user prefers native display', () => {
|
||||
expect(
|
||||
getSecondaryDisplayValue({
|
||||
rawAmount: '4500000',
|
||||
decimals: 6,
|
||||
exchangeRate: 1,
|
||||
displayCurrency: 'native',
|
||||
}),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('formats a USD secondary value from token units and exchange rate', () => {
|
||||
expect(
|
||||
getSecondaryDisplayValue({
|
||||
rawAmount: '4500000',
|
||||
decimals: 6,
|
||||
exchangeRate: 1,
|
||||
displayCurrency: 'usd',
|
||||
}),
|
||||
).toBe('$4.50')
|
||||
})
|
||||
|
||||
it('returns null when no usable exchange rate is available', () => {
|
||||
expect(
|
||||
getSecondaryDisplayValue({
|
||||
rawAmount: '4500000',
|
||||
decimals: 6,
|
||||
exchangeRate: null,
|
||||
displayCurrency: 'usd',
|
||||
}),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,35 +0,0 @@
|
||||
import { formatUnits } from './format'
|
||||
|
||||
function toFiniteNumber(value: string | number | null | undefined): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
const parsed = Number(value)
|
||||
if (Number.isFinite(parsed)) return parsed
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function formatUsdValue(value: number): string {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: Math.abs(value) >= 100 ? 0 : 2,
|
||||
}).format(value)
|
||||
}
|
||||
|
||||
export function getSecondaryDisplayValue(input: {
|
||||
rawAmount: string | number | null | undefined
|
||||
decimals?: number
|
||||
exchangeRate?: string | number | null
|
||||
displayCurrency: 'native' | 'usd'
|
||||
}): string | null {
|
||||
if (input.displayCurrency !== 'usd') return null
|
||||
|
||||
const exchangeRate = toFiniteNumber(input.exchangeRate)
|
||||
if (exchangeRate == null || exchangeRate < 0) return null
|
||||
|
||||
const normalizedAmount = Number(formatUnits(input.rawAmount, input.decimals ?? 18, 8))
|
||||
if (!Number.isFinite(normalizedAmount)) return null
|
||||
|
||||
return formatUsdValue(normalizedAmount * exchangeRate)
|
||||
}
|
||||
Reference in New Issue
Block a user