382 lines
12 KiB
Go
382 lines
12 KiB
Go
|
|
package rest
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"fmt"
|
||
|
|
"regexp"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
var (
|
||
|
|
addressPattern = regexp.MustCompile(`0x[a-fA-F0-9]{40}`)
|
||
|
|
transactionPattern = regexp.MustCompile(`0x[a-fA-F0-9]{64}`)
|
||
|
|
blockRefPattern = regexp.MustCompile(`(?i)\bblock\s+#?(\d+)\b`)
|
||
|
|
)
|
||
|
|
|
||
|
|
func (s *Server) buildAIContext(ctx context.Context, query string, pageContext map[string]string) (AIContextEnvelope, []string) {
|
||
|
|
warnings := []string{}
|
||
|
|
envelope := AIContextEnvelope{
|
||
|
|
ChainID: s.chainID,
|
||
|
|
Explorer: "SolaceScan",
|
||
|
|
PageContext: compactStringMap(pageContext),
|
||
|
|
CapabilityNotice: "This assistant is wired for read-only explorer analysis. It can summarize indexed chain data, liquidity routes, and curated workspace docs, but it does not sign transactions or execute private operations.",
|
||
|
|
}
|
||
|
|
|
||
|
|
sources := []AIContextSource{
|
||
|
|
{Type: "system", Label: "Explorer REST backend"},
|
||
|
|
}
|
||
|
|
|
||
|
|
if stats, err := s.queryAIStats(ctx); err == nil {
|
||
|
|
envelope.Stats = stats
|
||
|
|
sources = append(sources, AIContextSource{Type: "database", Label: "Explorer indexer database"})
|
||
|
|
} else if err != nil {
|
||
|
|
warnings = append(warnings, "indexed explorer stats unavailable: "+err.Error())
|
||
|
|
}
|
||
|
|
|
||
|
|
if strings.TrimSpace(query) != "" {
|
||
|
|
if txHash := firstRegexMatch(transactionPattern, query); txHash != "" && s.db != nil {
|
||
|
|
if tx, err := s.queryAITransaction(ctx, txHash); err == nil && len(tx) > 0 {
|
||
|
|
envelope.Transaction = tx
|
||
|
|
} else if err != nil {
|
||
|
|
warnings = append(warnings, "transaction context unavailable: "+err.Error())
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if addr := firstRegexMatch(addressPattern, query); addr != "" && s.db != nil {
|
||
|
|
if addressInfo, err := s.queryAIAddress(ctx, addr); err == nil && len(addressInfo) > 0 {
|
||
|
|
envelope.Address = addressInfo
|
||
|
|
} else if err != nil {
|
||
|
|
warnings = append(warnings, "address context unavailable: "+err.Error())
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if blockNumber := extractBlockReference(query); blockNumber > 0 && s.db != nil {
|
||
|
|
if block, err := s.queryAIBlock(ctx, blockNumber); err == nil && len(block) > 0 {
|
||
|
|
envelope.Block = block
|
||
|
|
} else if err != nil {
|
||
|
|
warnings = append(warnings, "block context unavailable: "+err.Error())
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if routeMatches, routeWarning := s.queryAIRoutes(ctx, query); len(routeMatches) > 0 {
|
||
|
|
envelope.RouteMatches = routeMatches
|
||
|
|
sources = append(sources, AIContextSource{Type: "routes", Label: "Token aggregation live routes", Origin: firstNonEmptyEnv("TOKEN_AGGREGATION_API_BASE", "TOKEN_AGGREGATION_URL", "TOKEN_AGGREGATION_BASE_URL")})
|
||
|
|
} else if routeWarning != "" {
|
||
|
|
warnings = append(warnings, routeWarning)
|
||
|
|
}
|
||
|
|
|
||
|
|
if docs, root, docWarning := loadAIDocSnippets(query); len(docs) > 0 {
|
||
|
|
envelope.DocSnippets = docs
|
||
|
|
sources = append(sources, AIContextSource{Type: "docs", Label: "Workspace docs", Origin: root})
|
||
|
|
} else if docWarning != "" {
|
||
|
|
warnings = append(warnings, docWarning)
|
||
|
|
}
|
||
|
|
|
||
|
|
envelope.Sources = sources
|
||
|
|
return envelope, uniqueStrings(warnings)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) queryAIStats(ctx context.Context) (map[string]any, error) {
|
||
|
|
if s.db == nil {
|
||
|
|
return nil, fmt.Errorf("database unavailable")
|
||
|
|
}
|
||
|
|
ctx, cancel := context.WithTimeout(ctx, 4*time.Second)
|
||
|
|
defer cancel()
|
||
|
|
|
||
|
|
stats := map[string]any{}
|
||
|
|
|
||
|
|
var totalBlocks int64
|
||
|
|
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM blocks WHERE chain_id = $1`, s.chainID).Scan(&totalBlocks); err == nil {
|
||
|
|
stats["total_blocks"] = totalBlocks
|
||
|
|
}
|
||
|
|
|
||
|
|
var totalTransactions int64
|
||
|
|
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM transactions WHERE chain_id = $1`, s.chainID).Scan(&totalTransactions); err == nil {
|
||
|
|
stats["total_transactions"] = totalTransactions
|
||
|
|
}
|
||
|
|
|
||
|
|
var totalAddresses int64
|
||
|
|
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM (
|
||
|
|
SELECT from_address AS address
|
||
|
|
FROM transactions
|
||
|
|
WHERE chain_id = $1 AND from_address IS NOT NULL AND from_address <> ''
|
||
|
|
UNION
|
||
|
|
SELECT to_address AS address
|
||
|
|
FROM transactions
|
||
|
|
WHERE chain_id = $1 AND to_address IS NOT NULL AND to_address <> ''
|
||
|
|
) unique_addresses`, s.chainID).Scan(&totalAddresses); err == nil {
|
||
|
|
stats["total_addresses"] = totalAddresses
|
||
|
|
}
|
||
|
|
|
||
|
|
var latestBlock int64
|
||
|
|
if err := s.db.QueryRow(ctx, `SELECT COALESCE(MAX(number), 0) FROM blocks WHERE chain_id = $1`, s.chainID).Scan(&latestBlock); err == nil {
|
||
|
|
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")
|
||
|
|
}
|
||
|
|
return stats, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) queryAITransaction(ctx context.Context, hash string) (map[string]any, error) {
|
||
|
|
ctx, cancel := context.WithTimeout(ctx, 4*time.Second)
|
||
|
|
defer cancel()
|
||
|
|
|
||
|
|
query := `
|
||
|
|
SELECT hash, block_number, from_address, to_address, value, gas_used, gas_price, status, timestamp_iso
|
||
|
|
FROM transactions
|
||
|
|
WHERE chain_id = $1 AND hash = $2
|
||
|
|
LIMIT 1
|
||
|
|
`
|
||
|
|
|
||
|
|
var txHash, fromAddress, value string
|
||
|
|
var blockNumber int64
|
||
|
|
var toAddress *string
|
||
|
|
var gasUsed, gasPrice *int64
|
||
|
|
var status *int64
|
||
|
|
var timestampISO *string
|
||
|
|
|
||
|
|
err := s.db.QueryRow(ctx, query, s.chainID, hash).Scan(
|
||
|
|
&txHash, &blockNumber, &fromAddress, &toAddress, &value, &gasUsed, &gasPrice, &status, ×tampISO,
|
||
|
|
)
|
||
|
|
if err != nil {
|
||
|
|
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, ×tampISO,
|
||
|
|
); fallbackErr != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
tx := map[string]any{
|
||
|
|
"hash": txHash,
|
||
|
|
"block_number": blockNumber,
|
||
|
|
"from_address": fromAddress,
|
||
|
|
"value": value,
|
||
|
|
}
|
||
|
|
if toAddress != nil {
|
||
|
|
tx["to_address"] = *toAddress
|
||
|
|
}
|
||
|
|
if gasUsed != nil {
|
||
|
|
tx["gas_used"] = *gasUsed
|
||
|
|
}
|
||
|
|
if gasPrice != nil {
|
||
|
|
tx["gas_price"] = *gasPrice
|
||
|
|
}
|
||
|
|
if status != nil {
|
||
|
|
tx["status"] = *status
|
||
|
|
}
|
||
|
|
if timestampISO != nil {
|
||
|
|
tx["timestamp_iso"] = *timestampISO
|
||
|
|
}
|
||
|
|
return tx, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) queryAIAddress(ctx context.Context, address string) (map[string]any, error) {
|
||
|
|
ctx, cancel := context.WithTimeout(ctx, 4*time.Second)
|
||
|
|
defer cancel()
|
||
|
|
|
||
|
|
address = normalizeAddress(address)
|
||
|
|
|
||
|
|
result := map[string]any{
|
||
|
|
"address": address,
|
||
|
|
}
|
||
|
|
|
||
|
|
var txCount int64
|
||
|
|
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM transactions WHERE chain_id = $1 AND (LOWER(from_address) = $2 OR LOWER(to_address) = $2)`, s.chainID, address).Scan(&txCount); err == nil {
|
||
|
|
result["transaction_count"] = txCount
|
||
|
|
}
|
||
|
|
|
||
|
|
var tokenCount int64
|
||
|
|
if err := s.db.QueryRow(ctx, `SELECT COUNT(DISTINCT token_contract) FROM token_transfers WHERE chain_id = $1 AND (LOWER(from_address) = $2 OR LOWER(to_address) = $2)`, s.chainID, address).Scan(&tokenCount); err == nil {
|
||
|
|
result["token_count"] = tokenCount
|
||
|
|
}
|
||
|
|
|
||
|
|
var recentHashes []string
|
||
|
|
rows, err := s.db.Query(ctx, `
|
||
|
|
SELECT hash
|
||
|
|
FROM transactions
|
||
|
|
WHERE chain_id = $1 AND (LOWER(from_address) = $2 OR LOWER(to_address) = $2)
|
||
|
|
ORDER BY block_number DESC, transaction_index DESC
|
||
|
|
LIMIT 5
|
||
|
|
`, s.chainID, address)
|
||
|
|
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 {
|
||
|
|
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")
|
||
|
|
}
|
||
|
|
return result, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) queryAIBlock(ctx context.Context, blockNumber int64) (map[string]any, error) {
|
||
|
|
ctx, cancel := context.WithTimeout(ctx, 4*time.Second)
|
||
|
|
defer cancel()
|
||
|
|
|
||
|
|
query := `
|
||
|
|
SELECT number, hash, parent_hash, transaction_count, gas_used, gas_limit, timestamp_iso
|
||
|
|
FROM blocks
|
||
|
|
WHERE chain_id = $1 AND number = $2
|
||
|
|
LIMIT 1
|
||
|
|
`
|
||
|
|
|
||
|
|
var number int64
|
||
|
|
var hash, parentHash string
|
||
|
|
var transactionCount int64
|
||
|
|
var gasUsed, gasLimit int64
|
||
|
|
var timestampISO *string
|
||
|
|
|
||
|
|
err := s.db.QueryRow(ctx, query, s.chainID, blockNumber).Scan(&number, &hash, &parentHash, &transactionCount, &gasUsed, &gasLimit, ×tampISO)
|
||
|
|
if err != nil {
|
||
|
|
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, ×tampISO); fallbackErr != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
block := map[string]any{
|
||
|
|
"number": number,
|
||
|
|
"hash": hash,
|
||
|
|
"parent_hash": parentHash,
|
||
|
|
"transaction_count": transactionCount,
|
||
|
|
"gas_used": gasUsed,
|
||
|
|
"gas_limit": gasLimit,
|
||
|
|
}
|
||
|
|
if timestampISO != nil {
|
||
|
|
block["timestamp_iso"] = *timestampISO
|
||
|
|
}
|
||
|
|
return block, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func extractBlockReference(query string) int64 {
|
||
|
|
match := blockRefPattern.FindStringSubmatch(query)
|
||
|
|
if len(match) != 2 {
|
||
|
|
return 0
|
||
|
|
}
|
||
|
|
var value int64
|
||
|
|
fmt.Sscan(match[1], &value)
|
||
|
|
return value
|
||
|
|
}
|