Harden explorer AI runtime and API ownership

This commit is contained in:
defiQUG
2026-03-27 14:12:14 -07:00
parent c9e792d55f
commit 3265e94da8
10 changed files with 774 additions and 24 deletions

View File

@@ -5,6 +5,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -121,10 +122,19 @@ type openAIOutputContent struct {
}
func (s *Server) handleAIContext(w http.ResponseWriter, r *http.Request) {
startedAt := time.Now()
clientIP := clientIPAddress(r)
if r.Method != http.MethodGet {
writeMethodNotAllowed(w)
return
}
if allowed, retryAfter := s.allowAIRequest(r, "context"); !allowed {
w.Header().Set("Retry-After", fmt.Sprintf("%.0f", retryAfter.Seconds()))
s.aiMetrics.Record("context", http.StatusTooManyRequests, time.Since(startedAt), "rate_limited", clientIP)
s.logAIRequest("context", http.StatusTooManyRequests, time.Since(startedAt), clientIP, explorerAIModel(), "rate_limited")
writeErrorDetailed(w, http.StatusTooManyRequests, "rate_limited", "explorer ai context rate limit exceeded", "please retry shortly")
return
}
query := strings.TrimSpace(r.URL.Query().Get("q"))
pageContext := map[string]string{
@@ -142,16 +152,28 @@ func (s *Server) handleAIContext(w http.ResponseWriter, r *http.Request) {
Warnings: warnings,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
s.aiMetrics.Record("context", http.StatusOK, time.Since(startedAt), "", clientIP)
s.logAIRequest("context", http.StatusOK, time.Since(startedAt), clientIP, explorerAIModel(), "")
writeJSON(w, http.StatusOK, response)
}
func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
startedAt := time.Now()
clientIP := clientIPAddress(r)
if r.Method != http.MethodPost {
writeMethodNotAllowed(w)
return
}
if allowed, retryAfter := s.allowAIRequest(r, "chat"); !allowed {
w.Header().Set("Retry-After", fmt.Sprintf("%.0f", retryAfter.Seconds()))
s.aiMetrics.Record("chat", http.StatusTooManyRequests, time.Since(startedAt), "rate_limited", clientIP)
s.logAIRequest("chat", http.StatusTooManyRequests, time.Since(startedAt), clientIP, explorerAIModel(), "rate_limited")
writeErrorDetailed(w, http.StatusTooManyRequests, "rate_limited", "explorer ai chat rate limit exceeded", "please retry shortly")
return
}
if !explorerAIEnabled() {
s.aiMetrics.Record("chat", http.StatusServiceUnavailable, time.Since(startedAt), "service_unavailable", clientIP)
s.logAIRequest("chat", http.StatusServiceUnavailable, time.Since(startedAt), clientIP, explorerAIModel(), "service_unavailable")
writeError(w, http.StatusServiceUnavailable, "service_unavailable", "explorer ai is not configured; set OPENAI_API_KEY on the backend")
return
}
@@ -176,7 +198,10 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
reply, model, err := s.callOpenAIResponses(r.Context(), messages, ctxEnvelope)
if err != nil {
writeError(w, http.StatusBadGateway, "bad_gateway", fmt.Sprintf("explorer ai request failed: %v", err))
statusCode, code, message, details := mapAIUpstreamError(err)
s.aiMetrics.Record("chat", statusCode, time.Since(startedAt), code, clientIP)
s.logAIRequest("chat", statusCode, time.Since(startedAt), clientIP, model, code)
writeErrorDetailed(w, statusCode, code, message, details)
return
}
@@ -188,8 +213,9 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
Warnings: warnings,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
s.aiMetrics.Record("chat", http.StatusOK, time.Since(startedAt), "", clientIP)
s.logAIRequest("chat", http.StatusOK, time.Since(startedAt), clientIP, model, "")
writeJSON(w, http.StatusOK, response)
}
func explorerAIEnabled() bool {
@@ -309,6 +335,28 @@ func (s *Server) queryAIStats(ctx context.Context) (map[string]any, error) {
stats["latest_block"] = latestBlock
}
if len(stats) == 0 {
var totalBlocks int64
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM blocks`).Scan(&totalBlocks); err == nil {
stats["total_blocks"] = totalBlocks
}
var totalTransactions int64
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM transactions`).Scan(&totalTransactions); err == nil {
stats["total_transactions"] = totalTransactions
}
var totalAddresses int64
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM addresses`).Scan(&totalAddresses); err == nil {
stats["total_addresses"] = totalAddresses
}
var latestBlock int64
if err := s.db.QueryRow(ctx, `SELECT COALESCE(MAX(number), 0) FROM blocks`).Scan(&latestBlock); err == nil {
stats["latest_block"] = latestBlock
}
}
if len(stats) == 0 {
return nil, fmt.Errorf("no indexed stats available")
}
@@ -337,7 +385,30 @@ func (s *Server) queryAITransaction(ctx context.Context, hash string) (map[strin
&txHash, &blockNumber, &fromAddress, &toAddress, &value, &gasUsed, &gasPrice, &status, &timestampISO,
)
if err != nil {
return nil, err
normalizedHash := normalizeHexString(hash)
blockscoutQuery := `
SELECT
concat('0x', encode(hash, 'hex')) AS hash,
block_number,
concat('0x', encode(from_address_hash, 'hex')) AS from_address,
CASE
WHEN to_address_hash IS NULL THEN NULL
ELSE concat('0x', encode(to_address_hash, 'hex'))
END AS to_address,
COALESCE(value::text, '0') AS value,
gas_used,
gas_price,
status,
TO_CHAR(block_timestamp AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS timestamp_iso
FROM transactions
WHERE hash = decode($1, 'hex')
LIMIT 1
`
if fallbackErr := s.db.QueryRow(ctx, blockscoutQuery, normalizedHash).Scan(
&txHash, &blockNumber, &fromAddress, &toAddress, &value, &gasUsed, &gasPrice, &status, &timestampISO,
); fallbackErr != nil {
return nil, err
}
}
tx := map[string]any{
@@ -403,6 +474,63 @@ func (s *Server) queryAIAddress(ctx context.Context, address string) (map[string
result["recent_transactions"] = recentHashes
}
if len(result) == 1 {
normalizedAddress := normalizeHexString(address)
var blockscoutTxCount int64
var blockscoutTokenCount int64
blockscoutAddressQuery := `
SELECT
COALESCE(transactions_count, 0),
COALESCE(token_transfers_count, 0)
FROM addresses
WHERE hash = decode($1, 'hex')
LIMIT 1
`
if err := s.db.QueryRow(ctx, blockscoutAddressQuery, normalizedAddress).Scan(&blockscoutTxCount, &blockscoutTokenCount); err == nil {
result["transaction_count"] = blockscoutTxCount
result["token_count"] = blockscoutTokenCount
}
var liveTxCount int64
if err := s.db.QueryRow(ctx, `
SELECT COUNT(*)
FROM transactions
WHERE from_address_hash = decode($1, 'hex') OR to_address_hash = decode($1, 'hex')
`, normalizedAddress).Scan(&liveTxCount); err == nil && liveTxCount > 0 {
result["transaction_count"] = liveTxCount
}
var liveTokenCount int64
if err := s.db.QueryRow(ctx, `
SELECT COUNT(DISTINCT token_contract_address_hash)
FROM token_transfers
WHERE from_address_hash = decode($1, 'hex') OR to_address_hash = decode($1, 'hex')
`, normalizedAddress).Scan(&liveTokenCount); err == nil && liveTokenCount > 0 {
result["token_count"] = liveTokenCount
}
rows, err := s.db.Query(ctx, `
SELECT concat('0x', encode(hash, 'hex'))
FROM transactions
WHERE from_address_hash = decode($1, 'hex') OR to_address_hash = decode($1, 'hex')
ORDER BY block_number DESC, index DESC
LIMIT 5
`, normalizedAddress)
if err == nil {
defer rows.Close()
for rows.Next() {
var hash string
if scanErr := rows.Scan(&hash); scanErr == nil {
recentHashes = append(recentHashes, hash)
}
}
}
if len(recentHashes) > 0 {
result["recent_transactions"] = recentHashes
}
}
if len(result) == 1 {
return nil, fmt.Errorf("address not found")
}
@@ -428,7 +556,22 @@ func (s *Server) queryAIBlock(ctx context.Context, blockNumber int64) (map[strin
err := s.db.QueryRow(ctx, query, s.chainID, blockNumber).Scan(&number, &hash, &parentHash, &transactionCount, &gasUsed, &gasLimit, &timestampISO)
if err != nil {
return nil, err
blockscoutQuery := `
SELECT
number,
concat('0x', encode(hash, 'hex')) AS hash,
concat('0x', encode(parent_hash, 'hex')) AS parent_hash,
(SELECT COUNT(*) FROM transactions WHERE block_number = b.number) AS transaction_count,
gas_used,
gas_limit,
TO_CHAR(timestamp AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS timestamp_iso
FROM blocks b
WHERE number = $1
LIMIT 1
`
if fallbackErr := s.db.QueryRow(ctx, blockscoutQuery, blockNumber).Scan(&number, &hash, &parentHash, &transactionCount, &gasUsed, &gasLimit, &timestampISO); fallbackErr != nil {
return nil, err
}
}
block := map[string]any{
@@ -535,6 +678,11 @@ func filterAIRouteMatches(routes []map[string]any, query string) []map[string]an
return matches
}
func normalizeHexString(value string) string {
trimmed := strings.TrimSpace(strings.ToLower(value))
return strings.TrimPrefix(trimmed, "0x")
}
func routeMatchesQuery(route map[string]any, query string) bool {
fields := []string{
stringValue(route["routeId"]),
@@ -802,21 +950,44 @@ func (s *Server) callOpenAIResponses(ctx context.Context, messages []AIChatMessa
client := &http.Client{Timeout: 45 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", model, err
if errors.Is(err, context.DeadlineExceeded) {
return "", model, &AIUpstreamError{
StatusCode: http.StatusGatewayTimeout,
Code: "upstream_timeout",
Message: "explorer ai upstream timed out",
Details: "OpenAI request exceeded the configured timeout",
}
}
return "", model, &AIUpstreamError{
StatusCode: http.StatusBadGateway,
Code: "upstream_transport_error",
Message: "explorer ai upstream transport failed",
Details: err.Error(),
}
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", model, err
return "", model, &AIUpstreamError{
StatusCode: http.StatusBadGateway,
Code: "upstream_bad_response",
Message: "explorer ai upstream body could not be read",
Details: err.Error(),
}
}
if resp.StatusCode >= 400 {
return "", model, fmt.Errorf("openai responses api returned %d: %s", resp.StatusCode, clipString(string(responseBody), 400))
return "", model, parseOpenAIError(resp.StatusCode, responseBody)
}
var response openAIResponsesResponse
if err := json.Unmarshal(responseBody, &response); err != nil {
return "", model, fmt.Errorf("unable to decode openai response: %w", err)
return "", model, &AIUpstreamError{
StatusCode: http.StatusBadGateway,
Code: "upstream_bad_response",
Message: "explorer ai upstream returned invalid JSON",
Details: err.Error(),
}
}
reply := strings.TrimSpace(response.OutputText)
@@ -824,7 +995,12 @@ func (s *Server) callOpenAIResponses(ctx context.Context, messages []AIChatMessa
reply = strings.TrimSpace(extractOutputText(response.Output))
}
if reply == "" {
return "", model, fmt.Errorf("openai response did not include output text")
return "", model, &AIUpstreamError{
StatusCode: http.StatusBadGateway,
Code: "upstream_bad_response",
Message: "explorer ai upstream returned no output text",
Details: "OpenAI response did not include output_text or content text",
}
}
if strings.TrimSpace(response.Model) != "" {
model = response.Model
@@ -832,6 +1008,53 @@ func (s *Server) callOpenAIResponses(ctx context.Context, messages []AIChatMessa
return reply, model, nil
}
func parseOpenAIError(statusCode int, responseBody []byte) error {
var parsed struct {
Error struct {
Message string `json:"message"`
Type string `json:"type"`
Code string `json:"code"`
} `json:"error"`
}
_ = json.Unmarshal(responseBody, &parsed)
details := clipString(strings.TrimSpace(parsed.Error.Message), 280)
if details == "" {
details = clipString(strings.TrimSpace(string(responseBody)), 280)
}
switch statusCode {
case http.StatusUnauthorized, http.StatusForbidden:
return &AIUpstreamError{
StatusCode: statusCode,
Code: "upstream_auth_failed",
Message: "explorer ai upstream authentication failed",
Details: details,
}
case http.StatusTooManyRequests:
return &AIUpstreamError{
StatusCode: statusCode,
Code: "upstream_quota_exhausted",
Message: "explorer ai upstream quota exhausted",
Details: details,
}
case http.StatusRequestTimeout, http.StatusGatewayTimeout:
return &AIUpstreamError{
StatusCode: statusCode,
Code: "upstream_timeout",
Message: "explorer ai upstream timed out",
Details: details,
}
default:
return &AIUpstreamError{
StatusCode: statusCode,
Code: "upstream_error",
Message: "explorer ai upstream request failed",
Details: details,
}
}
}
func extractOutputText(items []openAIOutputItem) string {
parts := []string{}
for _, item := range items {