Compare commits

..

11 Commits

Author SHA1 Message Date
defiQUG
ac40184d6b Fix SolaceScan frontend service release path
All checks were successful
phoenix-deploy Deployed to explorer-live
Deploy Explorer Live / deploy (push) Successful in 2m10s
2026-04-29 06:42:20 -07:00
defiQUG
7a16ddccf7 Add verified contract source workspace
Some checks failed
phoenix-deploy Deploy failed: Command failed: bash scripts/deployment/phoenix-deploy-explorer-live-from-workspace.sh nginx: the configuration file /et
Deploy Explorer Live / deploy (push) Failing after 3m47s
2026-04-29 06:21:56 -07:00
defiQUG
1f5167aded Expose full verified contract source payloads 2026-04-29 06:21:36 -07:00
defiQUG
f5eb874210 Harden VMID 5000 frontend deploy server discovery 2026-04-29 06:19:32 -07:00
defiQUG
1aa81f454a feat(explorer): add live token/native pricing and legacy tx route compatibility
Some checks failed
phoenix-deploy Deploy failed: Command failed: bash scripts/deployment/phoenix-deploy-explorer-live-from-workspace.sh nginx: the configuration file /et
Deploy Explorer Live / deploy (push) Failing after 4m8s
2026-04-25 23:45:07 -07:00
Codex
1b5cebf505 Add Gitea live redeploy workflow
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 8s
phoenix-deploy Deployed to explorer-live
2026-04-23 09:51:01 -07:00
fe9edd842b Merge pull request 'security: tighten gitleaks regex + document history-purge audit trail' (#14) from devin/1776542851-harden-gitleaks-and-document-purge into master
Some checks failed
CI / Backend (go 1.23.x) (push) Successful in 51s
CI / Backend security scanners (push) Failing after 45s
CI / Frontend (node 20) (push) Successful in 2m5s
CI / gitleaks (secret scan) (push) Failing after 7s
e2e-full / e2e-full (push) Failing after 21s
2026-04-18 20:08:58 +00:00
fdb14dc420 security: tighten gitleaks regex for escaped form, document history-purge audit trail
Some checks failed
CI / Backend (go 1.23.x) (pull_request) Successful in 56s
CI / Backend security scanners (pull_request) Failing after 40s
CI / Frontend (node 20) (pull_request) Successful in 2m19s
CI / gitleaks (secret scan) (pull_request) Failing after 7s
e2e-full / e2e-full (pull_request) Has been skipped
Two small follow-ups to the out-of-band git-history rewrite that
purged L@ker$2010 / L@kers2010 / L@ker\$2010 from every branch and
tag:

.gitleaks.toml:
  - Regex was L@kers?\$?2010 which catches the expanded form but
    NOT the shell-escaped form (L@ker\$2010) that slipped past PR #3
    in scripts/setup-database.sh. PR #13 fixed the live leak but did
    not tighten the detector. New regex L@kers?\\?\$?2010 catches
    both forms so future pastes of either form fail CI.
  - Description rewritten without the literal password (the previous
    description was redacted by the history rewrite itself and read
    'Legacy hardcoded ... (***REDACTED-LEGACY-PW*** / ***REDACTED-LEGACY-PW***)'
    which was cryptic).

docs/SECURITY.md:
  - New 'History-purge audit trail' section recording what was done,
    how it was verified (0 literal password matches in any blob or
    commit message; 0 legacy-password findings from a post-rewrite
    gitleaks scan), and what operator cleanup is still required on
    the Gitea host to drop the 13 refs/pull/*/head refs that still
    pin the pre-rewrite commits (the update hook declined those refs
    over HTTPS, so only an admin on the Gitea VM can purge them via
    'git update-ref -d' + 'git gc --prune=now' in the bare repo).
  - New 'Re-introduction guard' subsection pointing at the tightened
    regex and commit 78e1ff5.

Verification:
  gitleaks detect --no-git --source . --config .gitleaks.toml   # 0 legacy hits
  git log --all -p | grep -cE 'L@ker\$2010|L@kers2010'         # 0
2026-04-18 20:08:13 +00:00
7c018965eb Merge pull request 'fix(scripts): require DB_PASSWORD env var in setup-database.sh' (#13) from devin/1776542488-fix-setup-database-hardcoded-password into master
Some checks failed
CI / Backend (go 1.23.x) (push) Has been cancelled
CI / Backend security scanners (push) Has been cancelled
CI / Frontend (node 20) (push) Has been cancelled
CI / gitleaks (secret scan) (push) Has been cancelled
2026-04-18 20:02:37 +00:00
78e1ff5dc8 fix(scripts): require DB_PASSWORD env var in setup-database.sh
PR #3 scrubbed ***REDACTED-LEGACY-PW*** from every env file, compose unit, and
deployment doc but missed scripts/setup-database.sh, which still hard-
coded DB_PASSWORD="***REDACTED-LEGACY-PW***" on line 17. That slipped past
gitleaks because the shell-escaped form (backslash-dollar) does not
match the L@kers?\$?2010 regex committed in .gitleaks.toml -- the
regex was written to catch the *expanded* form, not the source form.

This commit removes the hardcoded default and requires DB_PASSWORD to
be exported by the operator before running the script. Same pattern as
the rest of the PR #3 conversion (fail-fast at boot when a required
secret is unset) so there is no longer any legitimate reason for the
password string to live in the repo.

Verification:
  git grep -nE 'L@kers?\\?\$?2010' -- scripts/    # no matches
  bash -n scripts/setup-database.sh                   # clean
2026-04-18 20:01:46 +00:00
fbe0f3e4aa Merge pull request 'docs(swagger)+test(rest): document /auth/refresh + /auth/logout, add HTTP smoke tests' (#12) from devin/1776541136-docs-auth-refresh-logout-followups into master 2026-04-18 19:41:49 +00:00
32 changed files with 12238 additions and 5536 deletions

View File

@@ -0,0 +1,43 @@
name: Deploy Explorer Live
on:
workflow_dispatch:
push:
branches: [main, master]
paths:
- '.gitea/workflows/deploy-live.yml'
- 'backend/**'
- 'config/**'
- 'deployment/**'
- 'docs/**'
- 'frontend/**'
- 'scripts/**'
- 'package.json'
- 'package-lock.json'
- 'Makefile'
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Validate live deploy assets
run: |
test -f scripts/deploy-explorer-config-to-vmid5000.sh
test -f scripts/deploy-explorer-ai-to-vmid5000.sh
test -f scripts/deploy-next-frontend-to-vmid5000.sh
test -f deployment/LIVE_DEPLOYMENT_MAP.md
- name: Trigger explorer-live deployment
run: |
SHA="$(git rev-parse HEAD)"
BRANCH="${GITHUB_REF_NAME:-}"
if [ -z "$BRANCH" ] || [ "$BRANCH" = "HEAD" ]; then
BRANCH="$(git rev-parse --abbrev-ref HEAD)"
fi
curl -sSf -X POST "${{ secrets.PHOENIX_DEPLOY_URL }}" \
-H "Authorization: Bearer ${{ secrets.PHOENIX_DEPLOY_TOKEN }}" \
-H "Content-Type: application/json" \
-d "{\"repo\":\"${{ gitea.repository }}\",\"sha\":\"${SHA}\",\"branch\":\"${BRANCH}\",\"target\":\"explorer-live\"}"

View File

@@ -12,8 +12,8 @@ useDefault = true
[[rules]]
id = "explorer-legacy-db-password-L@ker"
description = "Legacy hardcoded Postgres / SSH password (***REDACTED-LEGACY-PW*** / ***REDACTED-LEGACY-PW***)"
regex = '''L@kers?\$?2010'''
description = "Legacy hardcoded Postgres / SSH password (redacted). Matches both the expanded form and the shell-escaped form (backslash-dollar) that appeared in scripts/setup-database.sh."
regex = '''L@kers?\\?\$?2010'''
tags = ["password", "explorer-legacy"]
[allowlist]

View File

@@ -520,7 +520,7 @@ func (s *Server) HandleMissionControlBridgeTrace(w http.ResponseWriter, r *http.
"from_registry": fromLabel,
"to": toAddr,
"to_registry": toLabel,
"blockscout_url": publicBase + "/tx/" + strings.ToLower(tx),
"blockscout_url": publicBase + "/transactions/" + strings.ToLower(tx),
"source": source,
}
if registryLoadErr != nil && len(reg) == 0 {

View File

@@ -177,7 +177,7 @@ func TestHandleMissionControlBridgeTraceLabelsFromRegistry(t *testing.T) {
require.Equal(t, strings.ToLower(toAddr), out.Data["to"])
require.Equal(t, "CHAIN138_SOURCE_BRIDGE", out.Data["from_registry"])
require.Equal(t, "CHAIN138_DEST_BRIDGE", out.Data["to_registry"])
require.Equal(t, "https://explorer.example.org/tx/0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", out.Data["blockscout_url"])
require.Equal(t, "https://explorer.example.org/transactions/0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", out.Data["blockscout_url"])
}
func TestHandleMissionControlBridgeTraceFallsBackToAddressInventoryLabels(t *testing.T) {

View File

@@ -0,0 +1,21 @@
DROP INDEX IF EXISTS idx_swap_events_token1_price;
DROP INDEX IF EXISTS idx_swap_events_token0_price;
DROP INDEX IF EXISTS idx_swap_events_chain_tx_log;
CREATE UNIQUE INDEX IF NOT EXISTS idx_swap_events_unique_log
ON swap_events (
chain_id,
pool_address,
COALESCE(transaction_hash, ''),
COALESCE(log_index, -1)
);
ALTER TABLE IF EXISTS swap_events
DROP COLUMN IF EXISTS to_address,
DROP COLUMN IF EXISTS sender,
DROP COLUMN IF EXISTS token1_price_usd,
DROP COLUMN IF EXISTS token0_price_usd,
DROP COLUMN IF EXISTS price_usd,
DROP COLUMN IF EXISTS amount1_out,
DROP COLUMN IF EXISTS amount0_out,
DROP COLUMN IF EXISTS amount1_in,
DROP COLUMN IF EXISTS amount0_in;

View File

@@ -0,0 +1,27 @@
-- Migration: Add per-token USD price columns to swap_events
-- Description: Aligns lightweight swap_events schema with token-aggregation writer and
-- enables historical OHLCV generation to derive token-specific candles
ALTER TABLE IF EXISTS swap_events ADD COLUMN IF NOT EXISTS amount0_in NUMERIC(78, 0) DEFAULT 0;
ALTER TABLE IF EXISTS swap_events ADD COLUMN IF NOT EXISTS amount1_in NUMERIC(78, 0) DEFAULT 0;
ALTER TABLE IF EXISTS swap_events ADD COLUMN IF NOT EXISTS amount0_out NUMERIC(78, 0) DEFAULT 0;
ALTER TABLE IF EXISTS swap_events ADD COLUMN IF NOT EXISTS amount1_out NUMERIC(78, 0) DEFAULT 0;
ALTER TABLE IF EXISTS swap_events ADD COLUMN IF NOT EXISTS price_usd NUMERIC(30, 8);
ALTER TABLE IF EXISTS swap_events ADD COLUMN IF NOT EXISTS token0_price_usd NUMERIC(30, 8);
ALTER TABLE IF EXISTS swap_events ADD COLUMN IF NOT EXISTS token1_price_usd NUMERIC(30, 8);
ALTER TABLE IF EXISTS swap_events ADD COLUMN IF NOT EXISTS sender VARCHAR(42);
ALTER TABLE IF EXISTS swap_events ADD COLUMN IF NOT EXISTS to_address VARCHAR(42);
CREATE INDEX IF NOT EXISTS idx_swap_events_token0_price
ON swap_events (chain_id, token0_address, timestamp DESC)
WHERE token0_price_usd IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_swap_events_token1_price
ON swap_events (chain_id, token1_address, timestamp DESC)
WHERE token1_price_usd IS NOT NULL;
DROP INDEX IF EXISTS idx_swap_events_unique_log;
DROP INDEX IF EXISTS idx_swap_events_chain_tx_log;
CREATE UNIQUE INDEX IF NOT EXISTS idx_swap_events_chain_tx_log
ON swap_events (chain_id, transaction_hash, log_index);

View File

@@ -20,6 +20,7 @@ That file reflects the live split deployment now in production:
- Frontend deploy: [`scripts/deploy-next-frontend-to-vmid5000.sh`](../scripts/deploy-next-frontend-to-vmid5000.sh)
- Config deploy: [`scripts/deploy-explorer-config-to-vmid5000.sh`](../scripts/deploy-explorer-config-to-vmid5000.sh)
- Explorer config/API deploy: [`scripts/deploy-explorer-ai-to-vmid5000.sh`](../scripts/deploy-explorer-ai-to-vmid5000.sh)
- Gitea live redeploy action: [`.gitea/workflows/deploy-live.yml`](../.gitea/workflows/deploy-live.yml), target `explorer-live`
- RPC/API-key edge enforcement: [`ACCESS_EDGE_ENFORCEMENT_RUNBOOK.md`](./ACCESS_EDGE_ENFORCEMENT_RUNBOOK.md)
- Public health audit: [`scripts/check-explorer-health.sh`](../scripts/check-explorer-health.sh)
- Full public smoke: [`check-explorer-e2e.sh`](../../scripts/verify/check-explorer-e2e.sh)

View File

@@ -224,7 +224,7 @@ User → chainlist.org → Search "DBIS" → Click "Add to MetaMask"
```
User → MetaMask → Click "View on Explorer"
→ MetaMask opens: https://explorer.d-bis.org/tx/{hash}
→ MetaMask opens: https://explorer.d-bis.org/transactions/{hash}
→ Blockscout displays transaction details
→ Blockscout API provides the data
```
@@ -285,4 +285,3 @@ User → MetaMask → View Token Balance
**Last Updated**: 2025-12-24
**Status**: Analysis Complete

View File

@@ -63,6 +63,58 @@ initial public review.
- Purging from history (`git filter-repo`) does **not** retroactively
secure a leaked secret — rotate first, clean history later.
## History-purge audit trail
Following the rotation checklist above, the legacy `L@ker$2010` /
`L@kers2010` / `L@ker\$2010` password strings were purged from every
branch and tag in this repository using `git filter-repo
--replace-text` followed by a `--replace-message` pass for commit
message text. The rewritten history was force-pushed with
`git push --mirror --force`.
Verification post-rewrite:
```
git log --all -p | grep -cE 'L@ker\$2010|L@kers2010|L@ker\\\$2010'
0
gitleaks detect --no-git --source . --config .gitleaks.toml
0 legacy-password findings
```
### Residual server-side state (not purgable from the client)
Gitea's `refs/pull/*/head` refs (the read-only mirror of each PR's
original head commit) **cannot be force-updated over HTTPS** — the
server's `update` hook declines them. After a history rewrite the
following cleanup must be performed **on the Gitea host** by an
administrator:
1. Run `gitea admin repo-sync-release-archive` and
`gitea doctor --run all --fix` if available.
2. Or manually, as the gitea user on the server:
```bash
cd /var/lib/gitea/data/gitea-repositories/d-bis/explorer-monorepo.git
git for-each-ref --format='%(refname)' 'refs/pull/*/head' | \
xargs -n1 git update-ref -d
git gc --prune=now --aggressive
```
3. Restart Gitea.
Until this server-side cleanup is performed, the 13 `refs/pull/*/head`
refs still pin the pre-rewrite commits containing the legacy
password. This does not affect branches, the default clone, or
`master` — but the old commits remain reachable by SHA through the
Gitea web UI (e.g. on the merged PR's **Files Changed** tab).
### Re-introduction guard
The `.gitleaks.toml` rule `explorer-legacy-db-password-L@ker` was
tightened from `L@kers?\$?2010` to `L@kers?\\?\$?2010` so it also
catches the shell-escaped form that slipped past the original PR #3
scrub (see commit `78e1ff5`). Future attempts to paste any variant of
the legacy password — in source, shell scripts, or env files — will
fail the `gitleaks` CI job wired in PR #5.
## Build-time / CI checks (wired in PR #5)
- `gitleaks` pre-commit + CI gate on every PR.

View File

@@ -1,5 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference types="next/navigation-types/compat/navigation" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information.
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -1,9 +1,17 @@
const path = require('path')
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
output: 'standalone',
outputFileTracingRoot: path.resolve(__dirname, '..', '..'),
async redirects() {
return [
{
source: '/tx/:hash',
destination: '/transactions/:hash',
permanent: true,
},
{
source: '/more',
destination: '/operations',

16155
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,7 @@
"smoke:routes": "node ./scripts/smoke-routes.mjs",
"start": "PORT=${PORT:-3000} node ./scripts/start-standalone.mjs",
"start:next": "next start",
"lint": "next lint",
"lint": "eslint src libs next.config.js --ext .js,.jsx,.ts,.tsx",
"type-check": "tsc --noEmit -p tsconfig.check.json",
"test": "npm run lint && npm run type-check",
"test:unit": "vitest run"
@@ -22,12 +22,12 @@
"dependencies": {
"@tanstack/react-query": "^5.14.2",
"autoprefixer": "^10.4.16",
"axios": "^1.6.2",
"axios": "^1.15.2",
"clsx": "^2.0.0",
"date-fns": "^3.0.6",
"js-sha3": "^0.9.3",
"next": "^14.0.4",
"postcss": "^8.4.32",
"next": "^15.5.15",
"postcss": "^8.5.10",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"tailwindcss": "^3.3.6",
@@ -35,11 +35,16 @@
},
"devDependencies": {
"@types/node": "^20.10.5",
"@types/prop-types": "^15.7.15",
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18",
"eslint": "^8.56.0",
"eslint-config-next": "^14.0.4",
"eslint-config-next": "^15.5.15",
"typescript": "^5.3.3",
"vitest": "^1.6.1"
"vitest": "^4.1.5"
},
"overrides": {
"esbuild": "^0.28.0",
"postcss": "^8.5.10"
}
}

View File

@@ -7,7 +7,20 @@ import process from 'node:process'
const projectRoot = process.cwd()
const standaloneRoot = path.join(projectRoot, '.next', 'standalone')
const standaloneNextRoot = path.join(standaloneRoot, '.next')
const standaloneServer = path.join(standaloneRoot, 'server.js')
function resolveStandaloneServer() {
const directServer = path.join(standaloneRoot, 'server.js')
if (existsSync(directServer)) {
return { serverPath: directServer, appRoot: standaloneRoot }
}
const nestedServer = path.join(standaloneRoot, 'explorer-monorepo', 'frontend', 'server.js')
if (existsSync(nestedServer)) {
return { serverPath: nestedServer, appRoot: path.dirname(nestedServer) }
}
return { serverPath: directServer, appRoot: standaloneRoot }
}
async function copyIfPresent(sourcePath, destinationPath) {
if (!existsSync(sourcePath)) {
@@ -19,15 +32,16 @@ async function copyIfPresent(sourcePath, destinationPath) {
}
async function main() {
if (!existsSync(standaloneServer)) {
const { serverPath, appRoot } = resolveStandaloneServer()
if (!existsSync(serverPath)) {
console.error('Standalone server build is missing. Run `npm run build` first.')
process.exit(1)
}
await copyIfPresent(path.join(projectRoot, '.next', 'static'), path.join(standaloneNextRoot, 'static'))
await copyIfPresent(path.join(projectRoot, 'public'), path.join(standaloneRoot, 'public'))
await copyIfPresent(path.join(projectRoot, 'public'), path.join(appRoot, 'public'))
const child = spawn(process.execPath, [standaloneServer], {
const child = spawn(process.execPath, [serverPath], {
stdio: 'inherit',
env: process.env,
})

View File

@@ -0,0 +1,357 @@
'use client'
import { FormEvent, useMemo, useState } from 'react'
import clsx from 'clsx'
import { Card } from '@/libs/frontend-ui-primitives'
import EntityBadge from '@/components/common/EntityBadge'
import { getExplorerApiBase } from '@/services/api/blockscout'
import type { ContractProfile, ContractSourceFile } from '@/services/api/contracts'
interface ContractCodeWorkspaceProps {
address: string
profile: ContractProfile
}
interface OutlineEntry {
type: 'contract' | 'interface' | 'library' | 'function' | 'event' | 'error'
name: string
line: number
}
const QUICK_PROMPTS = [
'What does this contract do?',
'What are the functions available in this contract?',
'Which functions can change state or move funds?',
'Who has special permissions or control in this contract?',
'What are potential risks or red flags in this contract?',
] as const
function makeFallbackSourceFile(profile: ContractProfile): ContractSourceFile | null {
if (!profile.source_code_preview && !profile.abi_full && !profile.abi) return null
return {
path: profile.contract_name ? `${profile.contract_name}.sol` : 'Contract.sol',
content: profile.source_code_full || profile.source_code_preview || profile.abi_full || profile.abi || '',
}
}
function parseOutline(content: string): OutlineEntry[] {
const entries: OutlineEntry[] = []
content.split('\n').forEach((line, index) => {
const lineNumber = index + 1
const typeMatch = line.match(/^\s*(?:abstract\s+)?(contract|interface|library)\s+([A-Za-z_][A-Za-z0-9_]*)/)
if (typeMatch) {
entries.push({
type: typeMatch[1] as OutlineEntry['type'],
name: typeMatch[2],
line: lineNumber,
})
return
}
const memberMatch = line.match(/^\s*(function|event|error)\s+([A-Za-z_][A-Za-z0-9_]*)/)
if (memberMatch) {
entries.push({
type: memberMatch[1] as OutlineEntry['type'],
name: memberMatch[2],
line: lineNumber,
})
}
})
return entries
}
function sourceExcerptForPrompt(files: ContractSourceFile[]): string {
return files
.slice(0, 4)
.map((file) => `File: ${file.path}\n${file.content.slice(0, 2600)}`)
.join('\n\n')
.slice(0, 5200)
}
export default function ContractCodeWorkspace({ address, profile }: ContractCodeWorkspaceProps) {
const files = useMemo(() => {
const normalized = profile.source_files?.length ? profile.source_files : []
const fallback = makeFallbackSourceFile(profile)
return normalized.length > 0 ? normalized : fallback ? [fallback] : []
}, [profile])
const [activeTab, setActiveTab] = useState<'source' | 'reader'>('source')
const [activePath, setActivePath] = useState(files[0]?.path || '')
const [prompt, setPrompt] = useState('What does this contract do?')
const [model, setModel] = useState('Explorer AI')
const [saveHistory, setSaveHistory] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [readerAnswer, setReaderAnswer] = useState('')
const [readerError, setReaderError] = useState('')
const [expanded, setExpanded] = useState(false)
const activeFile = files.find((file) => file.path === activePath) || files[0]
const outline = useMemo(() => parseOutline(activeFile?.content || ''), [activeFile?.content])
const sourceLines = useMemo(() => (activeFile?.content || '').split('\n'), [activeFile?.content])
const selectedFiles = files
const sourceAvailable = files.length > 0 && Boolean(activeFile?.content)
const handleCopySource = async () => {
if (!activeFile?.content || typeof navigator === 'undefined') return
await navigator.clipboard?.writeText(activeFile.content)
}
const handleCopyLink = async () => {
if (typeof navigator === 'undefined' || typeof window === 'undefined') return
await navigator.clipboard?.writeText(`${window.location.href.split('#')[0]}#contract-source`)
}
const askReader = async (question: string) => {
const trimmed = question.trim()
if (!trimmed || submitting) return
setPrompt(trimmed)
setReaderError('')
setReaderAnswer('')
setSubmitting(true)
try {
const context = [
`Contract address: ${address}`,
profile.contract_name ? `Contract name: ${profile.contract_name}` : '',
profile.compiler_version ? `Compiler: ${profile.compiler_version}` : '',
profile.license_type ? `License: ${profile.license_type}` : '',
profile.proxy_type ? `Proxy type: ${profile.proxy_type}` : '',
`Read methods: ${profile.read_methods.map((method) => method.signature).slice(0, 24).join(', ') || 'none reported'}`,
`Write methods: ${profile.write_methods.map((method) => method.signature).slice(0, 24).join(', ') || 'none reported'}`,
sourceAvailable ? `Verified source excerpts:\n${sourceExcerptForPrompt(selectedFiles)}` : 'Verified source text is not available.',
].filter(Boolean).join('\n')
const response = await fetch(`${getExplorerApiBase()}/api/v1/ai/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: [
{
role: 'user',
content: `${trimmed}\n\nUse this contract context and answer concisely. Do not invent behavior that is not supported by the ABI or source.\n\n${context}`,
},
],
pageContext: {
path: `/addresses/${address}`,
view: 'contract-code-reader',
address,
},
}),
})
const payload = await response.json().catch(() => null)
if (!response.ok) {
throw new Error(payload?.error?.message || `AI reader returned HTTP ${response.status}`)
}
setReaderAnswer(String(payload?.reply || payload?.message?.content || 'No answer returned.'))
} catch (error) {
setReaderError(error instanceof Error ? error.message : 'Code Reader is temporarily unavailable.')
} finally {
setSubmitting(false)
}
}
const handleSubmit = async (event: FormEvent) => {
event.preventDefault()
await askReader(prompt)
}
if (!sourceAvailable && !profile.abi_available) {
return null
}
return (
<Card className="mb-6" title="Contract Source Code">
<section id="contract-source" className="space-y-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setActiveTab('source')}
className={clsx(
'rounded-lg px-3 py-2 text-sm font-semibold transition',
activeTab === 'source'
? 'bg-primary-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700',
)}
>
Source
</button>
<button
type="button"
onClick={() => setActiveTab('reader')}
className={clsx(
'rounded-lg px-3 py-2 text-sm font-semibold transition',
activeTab === 'reader'
? 'bg-primary-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700',
)}
>
Code Reader
</button>
</div>
<div className="flex flex-wrap gap-2">
{profile.source_verified ? <EntityBadge label="verified source" tone="success" /> : null}
{profile.abi_available ? <EntityBadge label="abi available" tone="info" /> : null}
{profile.compiler_version ? <EntityBadge label={profile.compiler_version} tone="neutral" className="normal-case tracking-normal" /> : null}
</div>
</div>
{activeTab === 'source' ? (
<div className={clsx('overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700', expanded ? 'min-h-[46rem]' : '')}>
<div className="grid lg:grid-cols-[18rem_minmax(0,1fr)]">
<aside className="border-b border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900 lg:border-b-0 lg:border-r">
<div className="flex items-center justify-between border-b border-gray-200 px-4 py-3 text-xs font-semibold uppercase text-gray-500 dark:border-gray-700 dark:text-gray-400">
<span>Explorer</span>
<span>{files.length} file{files.length === 1 ? '' : 's'}</span>
</div>
<div className="max-h-72 overflow-auto p-2 lg:max-h-[34rem]">
{files.map((file) => (
<button
type="button"
key={file.path}
onClick={() => setActivePath(file.path)}
className={clsx(
'block w-full rounded-md px-3 py-2 text-left text-sm transition',
file.path === activeFile?.path
? 'bg-white font-semibold text-gray-950 shadow-sm dark:bg-gray-800 dark:text-white'
: 'text-gray-700 hover:bg-white dark:text-gray-300 dark:hover:bg-gray-800',
)}
>
<span className="block truncate">{file.path}</span>
</button>
))}
</div>
{outline.length > 0 ? (
<div className="border-t border-gray-200 p-2 dark:border-gray-700">
<div className="px-3 py-2 text-xs font-semibold uppercase text-gray-500 dark:text-gray-400">Outline</div>
<div className="max-h-64 overflow-auto">
{outline.slice(0, 80).map((entry) => (
<button
key={`${entry.type}-${entry.name}-${entry.line}`}
type="button"
onClick={() => document.getElementById(`source-line-${entry.line}`)?.scrollIntoView({ block: 'center' })}
className="flex w-full items-center gap-2 rounded-md px-3 py-1.5 text-left text-xs text-gray-700 hover:bg-white dark:text-gray-300 dark:hover:bg-gray-800"
>
<span className="w-16 uppercase text-gray-400">{entry.type}</span>
<span className="min-w-0 flex-1 truncate font-mono">{entry.name}</span>
<span className="text-gray-400">{entry.line}</span>
</button>
))}
</div>
</div>
) : null}
</aside>
<div className="min-w-0 bg-gray-950 text-gray-100">
<div className="flex flex-col gap-3 border-b border-gray-800 bg-gray-900 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<div className="truncate font-mono text-sm text-white">{activeFile?.path || 'Source'}</div>
<div className="mt-1 text-xs text-gray-400">{sourceLines.length} lines</div>
</div>
<div className="flex flex-wrap gap-2">
<button type="button" onClick={handleCopySource} className="rounded-md border border-gray-700 px-3 py-1.5 text-xs font-semibold text-gray-200 hover:bg-gray-800">
Copy
</button>
<button type="button" onClick={handleCopyLink} className="rounded-md border border-gray-700 px-3 py-1.5 text-xs font-semibold text-gray-200 hover:bg-gray-800">
Link
</button>
<button type="button" onClick={() => setExpanded((value) => !value)} className="rounded-md border border-gray-700 px-3 py-1.5 text-xs font-semibold text-gray-200 hover:bg-gray-800">
{expanded ? 'Collapse' : 'Expand'}
</button>
</div>
</div>
<pre className={clsx('overflow-auto p-0 text-xs leading-5', expanded ? 'max-h-[52rem]' : 'max-h-[34rem]')}>
<code className="block min-w-max py-4">
{sourceLines.map((line, index) => (
<span id={`source-line-${index + 1}`} key={`${activeFile?.path}-${index}`} className="grid grid-cols-[4.5rem_minmax(0,1fr)] px-4 hover:bg-white/5">
<span className="select-none pr-4 text-right text-gray-500">{index + 1}</span>
<span className="whitespace-pre text-gray-100">{line || ' '}</span>
</span>
))}
</code>
</pre>
</div>
</div>
</div>
) : (
<div className="rounded-lg border border-gray-200 p-4 dark:border-gray-700">
<form onSubmit={handleSubmit} className="grid gap-5 lg:grid-cols-[28rem_minmax(0,1fr)]">
<div className="space-y-4 lg:border-r lg:border-gray-200 lg:pr-5 lg:dark:border-gray-700">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-gray-900 dark:text-white">Choose Model</span>
<select
value={model}
onChange={(event) => setModel(event.target.value)}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-950 dark:text-white"
>
<option>Explorer AI</option>
<option>Grok</option>
</select>
</label>
<div>
<div className="mb-2 text-sm font-semibold text-gray-900 dark:text-white">File Browser</div>
<div className="space-y-2 rounded-lg bg-gray-50 p-3 dark:bg-gray-900">
{files.map((file) => (
<label key={file.path} className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200">
<input type="checkbox" checked readOnly className="h-4 w-4 rounded border-gray-300 text-primary-600" />
<span className="truncate">{file.path}</span>
</label>
))}
</div>
</div>
</div>
<div className="min-w-0 space-y-4">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-gray-900 dark:text-white">Prompt</div>
<label className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-300">
<input type="checkbox" checked={saveHistory} onChange={(event) => setSaveHistory(event.target.checked)} className="h-4 w-4 rounded border-gray-300 text-primary-600" />
Save History
</label>
</div>
<div className="flex gap-3">
<textarea
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
rows={3}
className="min-h-24 flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-950 dark:text-white"
/>
<button
type="submit"
disabled={submitting || !prompt.trim()}
className="h-12 rounded-lg bg-primary-600 px-4 text-sm font-semibold text-white transition hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{submitting ? '...' : 'Send'}
</button>
</div>
<div className="flex flex-wrap gap-2">
{QUICK_PROMPTS.map((quickPrompt) => (
<button
key={quickPrompt}
type="button"
onClick={() => void askReader(quickPrompt)}
className="rounded-full border border-gray-300 px-3 py-1.5 text-xs font-semibold text-gray-700 hover:border-primary-400 hover:text-primary-700 dark:border-gray-700 dark:text-gray-300 dark:hover:text-primary-300"
>
{quickPrompt}
</button>
))}
</div>
{readerAnswer ? (
<div className="whitespace-pre-wrap rounded-lg bg-gray-50 p-4 text-sm text-gray-800 dark:bg-gray-900 dark:text-gray-100">
{readerAnswer}
</div>
) : null}
{readerError ? (
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-200">
{readerError}
</div>
) : null}
</div>
</form>
</div>
)}
</section>
</Card>
)
}

View File

@@ -23,6 +23,7 @@ import ActivityContextPanel from '@/components/common/ActivityContextPanel'
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
import { Explain, useUiMode } from '@/components/common/UiModeContext'
import { resolveEffectiveFreshness, shouldExplainEmptyHeadBlocks } from '@/utils/explorerFreshness'
import { tokenAggregationApi, type TokenAggregationTokenSnapshot } from '@/services/api/tokenAggregation'
type HomeStats = ExplorerStats
@@ -92,6 +93,15 @@ function compactStatNote(guided: string, expert: string, mode: 'guided' | 'exper
return mode === 'guided' ? guided : expert
}
function formatUsd(value: number | undefined) {
if (value == null || !Number.isFinite(value)) return 'Unavailable'
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: value >= 100 ? 0 : 2,
}).format(value)
}
export default function Home({
initialStats = null,
initialRecentBlocks = [],
@@ -109,6 +119,7 @@ export default function Home({
const [activitySnapshot, setActivitySnapshot] = useState<ExplorerRecentActivitySnapshot | null>(initialActivitySnapshot)
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
const [relaySummary, setRelaySummary] = useState<MissionControlRelaySummary | null>(initialRelaySummary)
const [featuredPrices, setFeaturedPrices] = useState<TokenAggregationTokenSnapshot[]>([])
const [missionExpanded, setMissionExpanded] = useState(false)
const [relayExpanded, setRelayExpanded] = useState(false)
const [relayPage, setRelayPage] = useState(1)
@@ -166,6 +177,29 @@ export default function Home({
}
}, [])
useEffect(() => {
let cancelled = false
tokenAggregationApi.getTokensByAddressSafe(138, [
'0x93E66202A11B1772E55407B32B44e5Cd8eda7f22',
'0xf22258f57794CC8E06237084b353Ab30fFfa640b',
'0x290e52a8819A4fBd0714e517225429AA2B70EC6B',
'0xc02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
]).then(({ data }) => {
if (!cancelled) {
setFeaturedPrices(data)
}
}).catch((error) => {
if (!cancelled && process.env.NODE_ENV !== 'production') {
console.warn('Failed to load featured token prices:', error)
}
})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
let cancelled = false
@@ -738,6 +772,35 @@ export default function Home({
</div>
)}
{featuredPrices.length > 0 ? (
<div className="mb-8">
<Card title="Live Price Feed">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{featuredPrices.map((token) => (
<Link
key={token.address}
href={`/tokens/${token.address}`}
className="rounded-2xl border border-gray-200 bg-gray-50/70 px-4 py-3 transition hover:border-primary-400 hover:shadow-sm dark:border-gray-800 dark:bg-gray-900/40"
>
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{token.symbol || token.name || 'Token'}
</div>
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
{formatUsd(token.market?.priceUsd)}
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
Visible liquidity: {formatUsd(token.market?.liquidityUsd)}
</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{token.market?.lastUpdated ? `Updated ${formatRelativeAge(token.market.lastUpdated)}` : 'Update time unavailable'}
</div>
</Link>
))}
</div>
</Card>
</div>
) : null}
<div className="mb-8">
<ActivityContextPanel
context={activityContext}

View File

@@ -29,13 +29,27 @@ import {
} from '@/utils/watchlist'
import PageIntro from '@/components/common/PageIntro'
import GruStandardsCard from '@/components/common/GruStandardsCard'
import ContractCodeWorkspace from '@/components/explorer/ContractCodeWorkspace'
import { getGruStandardsProfileSafe, type GruStandardsProfile } from '@/services/api/gru'
import { getGruExplorerMetadata } from '@/services/api/gruExplorerData'
import { tokenAggregationApi, type TokenAggregationTokenSnapshot } from '@/services/api/tokenAggregation'
import { estimateNativeUsdValue, getNativeAssetDescriptor, getNativeAssetMarketSafe } from '@/services/api/nativeAssetPricing'
function isValidAddress(value: string) {
return /^0x[a-fA-F0-9]{40}$/.test(value)
}
function formatUsd(value: string | number | undefined): string {
if (value == null) return 'Unavailable'
const numeric = typeof value === 'number' ? value : Number(value)
if (!Number.isFinite(numeric)) return 'Unavailable'
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: numeric >= 100 ? 0 : 2,
}).format(numeric)
}
export default function AddressDetailPage() {
const router = useRouter()
const address = typeof router.query.address === 'string' ? router.query.address : ''
@@ -51,6 +65,8 @@ export default function AddressDetailPage() {
const [watchlistEntries, setWatchlistEntries] = useState<string[]>([])
const [methodResults, setMethodResults] = useState<Record<string, { loading: boolean; value?: string; error?: string }>>({})
const [methodInputs, setMethodInputs] = useState<Record<string, string[]>>({})
const [tokenMarkets, setTokenMarkets] = useState<Record<string, TokenAggregationTokenSnapshot>>({})
const [nativeAssetPriceUsd, setNativeAssetPriceUsd] = useState<number | undefined>()
const [loading, setLoading] = useState(true)
const loadAddressInfo = useCallback(async () => {
@@ -137,6 +153,46 @@ export default function AddressDetailPage() {
}
}, [])
useEffect(() => {
let active = true
const tokenAddresses = [
...(addressInfo?.token_contract?.address ? [addressInfo.token_contract.address] : []),
...tokenBalances.map((balance) => balance.token_address),
...tokenTransfers.map((transfer) => transfer.token_address),
].filter((candidate, index, values): candidate is string => typeof candidate === 'string' && candidate.trim().length > 0 && values.indexOf(candidate) === index)
tokenAggregationApi.getTokensByAddressSafe(chainId, tokenAddresses).then(({ data }) => {
if (!active) return
setTokenMarkets(Object.fromEntries(data.map((snapshot) => [snapshot.address.toLowerCase(), snapshot])))
}).catch(() => {
if (active) {
setTokenMarkets({})
}
})
return () => {
active = false
}
}, [addressInfo?.token_contract?.address, chainId, tokenBalances, tokenTransfers])
useEffect(() => {
let active = true
getNativeAssetMarketSafe(chainId).then(({ data }) => {
if (!active) return
setNativeAssetPriceUsd(data?.market?.priceUsd)
}).catch(() => {
if (active) {
setNativeAssetPriceUsd(undefined)
}
})
return () => {
active = false
}
}, [chainId])
const watchlistAddress = normalizeWatchlistAddress(addressInfo?.address || address)
const isSavedToWatchlist = watchlistAddress
? isWatchlistEntry(watchlistEntries, watchlistAddress)
@@ -272,7 +328,17 @@ export default function AddressDetailPage() {
},
{
header: 'Value',
accessor: (tx: TransactionSummary) => formatWeiAsEth(tx.value),
accessor: (tx: TransactionSummary) => {
const nativeValueUsd = estimateNativeUsdValue(tx.value, nativeAssetPriceUsd)
return (
<div className="space-y-1 text-sm">
<div>{formatWeiAsEth(tx.value)}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{nativeValueUsd != null ? `Current USD: ${formatUsd(nativeValueUsd)}` : 'Current USD unavailable'}
</div>
</div>
)
},
},
{
header: 'Status',
@@ -327,6 +393,20 @@ export default function AddressDetailPage() {
: 'N/A'
),
},
{
header: 'Current Price',
accessor: (balance: AddressTokenBalance) => {
const market = tokenMarkets[balance.token_address.toLowerCase()]?.market
return (
<div className="space-y-1 text-sm">
<div>{formatUsd(market?.priceUsd)}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
Liq. {formatUsd(market?.liquidityUsd)}
</div>
</div>
)
},
},
]
const tokenTransferColumns = [
@@ -383,6 +463,20 @@ export default function AddressDetailPage() {
header: 'When',
accessor: (transfer: AddressTokenTransfer) => formatTimestamp(transfer.timestamp),
},
{
header: 'Current Price',
accessor: (transfer: AddressTokenTransfer) => {
const market = tokenMarkets[transfer.token_address.toLowerCase()]?.market
return (
<div className="space-y-1 text-sm">
<div>{formatUsd(market?.priceUsd)}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
Liq. {formatUsd(market?.liquidityUsd)}
</div>
</div>
)
},
},
]
const incomingTransactions = transactions.filter(
@@ -403,6 +497,8 @@ export default function AddressDetailPage() {
const gruTransferCount = tokenTransfers.filter((transfer) =>
Boolean(getGruExplorerMetadata({ address: transfer.token_address, symbol: transfer.token_symbol })),
).length
const nativeAssetSymbol = getNativeAssetDescriptor(chainId).symbol
const nativeBalanceUsd = estimateNativeUsdValue(addressInfo?.balance, nativeAssetPriceUsd)
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
@@ -473,8 +569,14 @@ export default function AddressDetailPage() {
<Address address={addressInfo.address} />
</DetailRow>
{addressInfo.balance && (
<DetailRow label="Coin Balance">{formatWeiAsEth(addressInfo.balance)}</DetailRow>
<DetailRow label="Coin Balance">
{formatWeiAsEth(addressInfo.balance)}
{nativeBalanceUsd != null ? ` (${formatUsd(nativeBalanceUsd)})` : ''}
</DetailRow>
)}
<DetailRow label="Current Native Asset Price">
{nativeAssetPriceUsd != null ? `${formatUsd(nativeAssetPriceUsd)} per ${nativeAssetSymbol}` : 'Unavailable'}
</DetailRow>
<DetailRow label="Watchlist">
{isSavedToWatchlist ? 'Saved for quick access' : 'Not saved yet'}
</DetailRow>
@@ -601,20 +703,6 @@ export default function AddressDetailPage() {
</code>
</DetailRow>
)}
{contractProfile?.source_code_preview && (
<DetailRow label="Source Preview">
<code className="block max-h-56 overflow-auto whitespace-pre-wrap break-words rounded bg-gray-50 p-2 text-xs dark:bg-gray-950">
{contractProfile.source_code_preview}
</code>
</DetailRow>
)}
{contractProfile?.abi && (
<DetailRow label="ABI Preview">
<code className="block max-h-56 overflow-auto whitespace-pre-wrap break-words rounded bg-gray-50 p-2 text-xs dark:bg-gray-950">
{contractProfile.abi}
</code>
</DetailRow>
)}
{contractProfile?.read_methods && contractProfile.read_methods.length > 0 && (
<DetailRow label="Read Methods">
<div className="space-y-3">
@@ -760,6 +848,10 @@ export default function AddressDetailPage() {
</Card>
)}
{addressInfo.is_contract && contractProfile ? (
<ContractCodeWorkspace address={addressInfo.address} profile={contractProfile} />
) : null}
{gruProfile ? <div className="mb-6"><GruStandardsCard profile={gruProfile} /></div> : null}
<Card title="Token Balances" className="mb-6">

View File

@@ -4,6 +4,7 @@ import { useRouter } from 'next/router'
import { Card, Address } from '@/libs/frontend-ui-primitives'
import Link from 'next/link'
import { configApi, type TokenListToken } from '@/services/api/config'
import { tokenAggregationApi, type TokenAggregationTokenSnapshot } from '@/services/api/tokenAggregation'
import EntityBadge from '@/components/common/EntityBadge'
import {
inferDirectSearchTarget,
@@ -24,6 +25,15 @@ interface SearchPageProps {
initialCuratedTokens: TokenListToken[]
}
function formatUsd(value: number | undefined): string {
if (value == null || !Number.isFinite(value)) return 'Unavailable'
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: value >= 100 ? 0 : 2,
}).format(value)
}
export default function SearchPage({
initialQuery,
initialRawResults,
@@ -40,6 +50,7 @@ export default function SearchPage({
const [curatedTokens, setCuratedTokens] = useState<TokenListToken[]>(initialCuratedTokens)
const [savedQueries, setSavedQueries] = useState<string[]>([])
const [filterMode, setFilterMode] = useState<SearchFilterMode>('all')
const [tokenMarkets, setTokenMarkets] = useState<Record<string, TokenAggregationTokenSnapshot>>({})
const runSearch = async (rawQuery: string) => {
const trimmedQuery = rawQuery.trim()
@@ -190,6 +201,27 @@ export default function SearchPage({
{ label: 'Other', items: groupedResults.other },
]
useEffect(() => {
let active = true
const tokenAddresses = filteredResults
.filter((result) => result.type === 'token' && typeof result.data.address === 'string' && result.data.address.trim().length > 0)
.map((result) => result.data.address as string)
tokenAggregationApi.getTokensByAddressSafe(138, tokenAddresses).then(({ data }) => {
if (!active) return
setTokenMarkets(Object.fromEntries(data.map((snapshot) => [snapshot.address.toLowerCase(), snapshot])))
}).catch(() => {
if (active) {
setTokenMarkets({})
}
})
return () => {
active = false
}
}, [filteredResults])
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
<PageIntro
@@ -341,30 +373,43 @@ export default function SearchPage({
</Link>
)}
{(result.type === 'address' || result.type === 'token') && result.data.address && (
<Link
href={result.href || (result.type === 'token' ? `/tokens/${result.data.address}` : `/addresses/${result.data.address}`)}
className="inline-flex flex-col gap-2 text-primary-600 hover:underline"
>
<div className="flex flex-wrap items-center gap-2">
<EntityBadge
label={result.type === 'token' ? 'token' : 'address'}
tone={result.type === 'token' ? 'success' : 'neutral'}
/>
{result.symbol && <EntityBadge label={result.symbol} tone="info" />}
{result.token_type && <EntityBadge label={result.token_type} tone="warning" />}
{result.is_curated_token && <EntityBadge label="listed" tone="success" />}
{result.is_gru_token && <EntityBadge label="GRU" tone="success" />}
{result.is_x402_ready && <EntityBadge label="x402 ready" tone="info" />}
{result.is_wrapped_transport && <EntityBadge label="wrapped" tone="warning" />}
{result.currency_code ? <EntityBadge label={result.currency_code} tone="neutral" /> : null}
{result.match_reason ? <EntityBadge label={result.match_reason} tone="info" className="normal-case tracking-normal" /> : null}
{result.matched_tags?.map((tag) => <EntityBadge key={tag} label={tag} />)}
</div>
<span className="font-medium text-gray-900 dark:text-white">
{result.name || result.symbol || result.label}
</span>
<Address address={result.data.address} truncate showCopy={false} />
</Link>
(() => {
const market = result.type === 'token'
? tokenMarkets[result.data.address.toLowerCase()]?.market
: null
return (
<Link
href={result.href || (result.type === 'token' ? `/tokens/${result.data.address}` : `/addresses/${result.data.address}`)}
className="inline-flex flex-col gap-2 text-primary-600 hover:underline"
>
<div className="flex flex-wrap items-center gap-2">
<EntityBadge
label={result.type === 'token' ? 'token' : 'address'}
tone={result.type === 'token' ? 'success' : 'neutral'}
/>
{result.symbol && <EntityBadge label={result.symbol} tone="info" />}
{result.token_type && <EntityBadge label={result.token_type} tone="warning" />}
{result.is_curated_token && <EntityBadge label="listed" tone="success" />}
{result.is_gru_token && <EntityBadge label="GRU" tone="success" />}
{result.is_x402_ready && <EntityBadge label="x402 ready" tone="info" />}
{result.is_wrapped_transport && <EntityBadge label="wrapped" tone="warning" />}
{result.currency_code ? <EntityBadge label={result.currency_code} tone="neutral" /> : null}
{result.match_reason ? <EntityBadge label={result.match_reason} tone="info" className="normal-case tracking-normal" /> : null}
{result.matched_tags?.map((tag) => <EntityBadge key={tag} label={tag} />)}
</div>
<span className="font-medium text-gray-900 dark:text-white">
{result.name || result.symbol || result.label}
</span>
<Address address={result.data.address} truncate showCopy={false} />
{market ? (
<div className="flex flex-wrap gap-x-3 gap-y-1 text-sm text-gray-500 dark:text-gray-400">
<span>Live price: {formatUsd(market.priceUsd)}</span>
<span>Visible liquidity: {formatUsd(market.liquidityUsd)}</span>
</div>
) : null}
</Link>
)
})()
)}
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-sm text-gray-500">
<span>Type: {result.type}</span>

View File

@@ -346,9 +346,12 @@ export default function TokenDetailPage() {
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Market Context</div>
<div className="mt-3 space-y-2 text-sm text-gray-700 dark:text-gray-300">
<div>Indicative price: {formatUsd(token.exchange_rate)}</div>
<div>Current price: {formatUsd(token.exchange_rate)}</div>
<div>24h volume: {formatUsd(token.volume_24h)}</div>
<div>Market cap: {formatUsd(token.circulating_market_cap)}</div>
<div>Visible liquidity: {formatUsd(token.liquidity_usd)}</div>
<div>Valuation source: {token.price_source === 'token-aggregation' ? 'live token aggregation' : token.price_source || 'unavailable'}</div>
<div>Market snapshot: {token.market_updated_at ? formatTimestamp(token.market_updated_at) : 'Unavailable'}</div>
</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">

View File

@@ -7,6 +7,7 @@ import PageIntro from '@/components/common/PageIntro'
import EntityBadge from '@/components/common/EntityBadge'
import { tokensApi } from '@/services/api/tokens'
import type { TokenListToken } from '@/services/api/config'
import { tokenAggregationApi, type TokenAggregationTokenSnapshot } from '@/services/api/tokenAggregation'
import { fetchPublicJson } from '@/utils/publicExplorer'
const quickSearches = [
@@ -27,10 +28,20 @@ interface TokensPageProps {
initialCuratedTokens: TokenListToken[]
}
function formatUsd(value: number | undefined): string {
if (value == null || !Number.isFinite(value)) return 'Unavailable'
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: value >= 100 ? 0 : 2,
}).format(value)
}
export default function TokensPage({ initialCuratedTokens }: TokensPageProps) {
const router = useRouter()
const [query, setQuery] = useState('')
const [curatedTokens, setCuratedTokens] = useState<TokenListToken[]>(initialCuratedTokens)
const [featuredMarkets, setFeaturedMarkets] = useState<Record<string, TokenAggregationTokenSnapshot>>({})
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault()
@@ -68,6 +79,28 @@ export default function TokensPage({ initialCuratedTokens }: TokensPageProps) {
return selected.length > 0 ? selected : curatedTokens.slice(0, 6)
}, [curatedTokens])
useEffect(() => {
let active = true
const featuredAddresses = featuredCuratedTokens
.map((token) => token.address)
.filter((address): address is string => typeof address === 'string' && address.trim().length > 0)
tokenAggregationApi.getTokensByAddressSafe(138, featuredAddresses).then(({ data }) => {
if (!active) return
const next = Object.fromEntries(data.map((snapshot) => [snapshot.address.toLowerCase(), snapshot]))
setFeaturedMarkets(next)
}).catch(() => {
if (active) {
setFeaturedMarkets({})
}
})
return () => {
active = false
}
}, [featuredCuratedTokens])
return (
<div className="container mx-auto px-4 py-8">
<PageIntro
@@ -139,25 +172,36 @@ export default function TokensPage({ initialCuratedTokens }: TokensPageProps) {
<div className="mt-8">
<Card title="Curated Chain 138 tokens">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{featuredCuratedTokens.map((token) => (
<Link
key={token.address}
href={`/tokens/${token.address}`}
className="rounded-lg border border-gray-200 p-4 transition hover:border-primary-400 hover:shadow-sm dark:border-gray-700"
>
<div className="text-sm font-semibold text-gray-900 dark:text-white">{token.symbol || token.name || 'Token'}</div>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
{token.name || 'Listed in the Chain 138 token registry.'}
</p>
{token.tags && token.tags.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{token.tags.slice(0, 3).map((tag) => (
<EntityBadge key={`${token.address}-${tag}`} label={tag} className="px-2 py-1 text-[11px]" />
))}
</div>
)}
</Link>
))}
{featuredCuratedTokens
.filter((token): token is TokenListToken & { address: string } => typeof token.address === 'string' && token.address.trim().length > 0)
.map((token) => {
const market = featuredMarkets[token.address.toLowerCase()]?.market
return (
<Link
key={token.address}
href={`/tokens/${token.address}`}
className="rounded-lg border border-gray-200 p-4 transition hover:border-primary-400 hover:shadow-sm dark:border-gray-700"
>
<div className="text-sm font-semibold text-gray-900 dark:text-white">{token.symbol || token.name || 'Token'}</div>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
{token.name || 'Listed in the Chain 138 token registry.'}
</p>
{market ? (
<div className="mt-3 space-y-1 text-sm text-gray-700 dark:text-gray-300">
<div>Live price: {formatUsd(market.priceUsd)}</div>
<div>Visible liquidity: {formatUsd(market.liquidityUsd)}</div>
</div>
) : null}
{token.tags && token.tags.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{token.tags.slice(0, 3).map((tag) => (
<EntityBadge key={`${token.address}-${tag}`} label={tag} className="px-2 py-1 text-[11px]" />
))}
</div>
)}
</Link>
)
})}
</div>
</Card>
</div>

View File

@@ -17,11 +17,47 @@ import EntityBadge from '@/components/common/EntityBadge'
import PageIntro from '@/components/common/PageIntro'
import { getGruCatalogPosture } from '@/services/api/gruCatalog'
import { assessTransactionCompliance } from '@/utils/transactionCompliance'
import { tokenAggregationApi, type TokenAggregationHistoricalPriceSnapshot } from '@/services/api/tokenAggregation'
import { estimateNativeUsdValue, estimateTokenUsdValue, getNativeAssetDescriptor, getNativeAssetPriceAtSafe } from '@/services/api/nativeAssetPricing'
function isValidTransactionHash(value: string) {
return /^0x[a-fA-F0-9]{64}$/.test(value)
}
function formatUsd(value: string | number | undefined): string {
if (value == null) return 'Unavailable'
const numeric = typeof value === 'number' ? value : Number(value)
if (!Number.isFinite(numeric)) return 'Unavailable'
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: numeric >= 100 ? 0 : 2,
}).format(numeric)
}
function formatHistoricalPriceSource(source: string | undefined): string {
switch (source) {
case 'ohlcv_5m':
return 'historical OHLCV 5m'
case 'ohlcv_15m':
return 'historical OHLCV 15m'
case 'ohlcv_1h':
return 'historical OHLCV 1h'
case 'ohlcv_4h':
return 'historical OHLCV 4h'
case 'ohlcv_24h':
return 'historical OHLCV 24h'
case 'coingecko_history':
return 'historical CoinGecko market'
case 'current_market_fallback':
return 'current market fallback'
case 'canonical_fallback':
return 'canonical fallback'
default:
return 'unavailable'
}
}
export default function TransactionDetailPage() {
const router = useRouter()
const hash = typeof router.query.hash === 'string' ? router.query.hash : ''
@@ -31,6 +67,8 @@ export default function TransactionDetailPage() {
const [transaction, setTransaction] = useState<Transaction | null>(null)
const [internalCalls, setInternalCalls] = useState<TransactionInternalCall[]>([])
const [diagnostic, setDiagnostic] = useState<TransactionLookupDiagnostic | null>(null)
const [historicalTokenPrices, setHistoricalTokenPrices] = useState<Record<string, TokenAggregationHistoricalPriceSnapshot>>({})
const [historicalNativePrice, setHistoricalNativePrice] = useState<TokenAggregationHistoricalPriceSnapshot | null>(null)
const [loading, setLoading] = useState(true)
const loadTransaction = useCallback(async () => {
@@ -91,6 +129,62 @@ export default function TransactionDetailPage() {
loadTransaction()
}, [hash, isValidHash, loadTransaction, router.isReady])
useEffect(() => {
let active = true
const tokenAddresses = (transaction?.token_transfers || [])
.map((transfer) => transfer.token_address)
.filter((candidate, index, values): candidate is string => typeof candidate === 'string' && candidate.trim().length > 0 && values.indexOf(candidate) === index)
if (!transaction?.created_at || tokenAddresses.length === 0) {
setHistoricalTokenPrices({})
return () => {
active = false
}
}
Promise.all(tokenAddresses.map((address) => tokenAggregationApi.getPriceAtSafe(chainId, address, transaction.created_at))).then((results) => {
if (!active) return
const snapshots = results
.filter((result): result is { ok: true; data: TokenAggregationHistoricalPriceSnapshot | null } => result.ok)
.map((result) => result.data)
.filter((snapshot): snapshot is TokenAggregationHistoricalPriceSnapshot => Boolean(snapshot?.tokenAddress))
setHistoricalTokenPrices(Object.fromEntries(snapshots.map((snapshot) => [snapshot.tokenAddress.toLowerCase(), snapshot])))
}).catch(() => {
if (active) {
setHistoricalTokenPrices({})
}
})
return () => {
active = false
}
}, [chainId, transaction?.created_at, transaction?.token_transfers])
useEffect(() => {
let active = true
if (!transaction?.created_at) {
setHistoricalNativePrice(null)
return () => {
active = false
}
}
getNativeAssetPriceAtSafe(chainId, transaction.created_at).then(({ data }) => {
if (!active) return
setHistoricalNativePrice(data)
}).catch(() => {
if (active) {
setHistoricalNativePrice(null)
}
})
return () => {
active = false
}
}, [chainId, transaction?.created_at])
const tokenTransferColumns = [
{
header: 'Token',
@@ -137,6 +231,24 @@ export default function TransactionDetailPage() {
formatTokenAmount(transfer.amount, transfer.token_decimals, transfer.token_symbol)
),
},
{
header: 'Transfer-Time Value',
accessor: (transfer: TransactionTokenTransfer) => {
const historicalPrice = historicalTokenPrices[transfer.token_address.toLowerCase()]
const totalUsd = estimateTokenUsdValue(transfer.amount, transfer.token_decimals, historicalPrice?.priceUsd)
return (
<div className="space-y-1 text-sm">
<div>{totalUsd != null ? formatUsd(totalUsd) : 'Unavailable'}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
Unit price: {formatUsd(historicalPrice?.priceUsd)}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
Source: {formatHistoricalPriceSource(historicalPrice?.source)}
</div>
</div>
)
},
},
]
const internalCallColumns = [
@@ -186,6 +298,9 @@ export default function TransactionDetailPage() {
: null
const tokenTransferCount = transaction?.token_transfers?.length || 0
const internalCallCount = internalCalls.length
const nativeAssetSymbol = getNativeAssetDescriptor(chainId).symbol
const nativeValueUsd = estimateNativeUsdValue(transaction?.value, historicalNativePrice?.priceUsd)
const nativeFeeUsd = estimateNativeUsdValue(transaction?.fee, historicalNativePrice?.priceUsd)
const complianceAssessment = transaction
? assessTransactionCompliance({
transaction,
@@ -275,7 +390,10 @@ export default function TransactionDetailPage() {
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Gas & Fees</div>
<div className="mt-3 space-y-2 text-sm text-gray-700 dark:text-gray-300">
<div>Fee: {transaction.fee ? formatWeiAsEth(transaction.fee) : 'Unavailable'}</div>
<div>
Fee: {transaction.fee ? formatWeiAsEth(transaction.fee) : 'Unavailable'}
{nativeFeeUsd != null ? ` (${formatUsd(nativeFeeUsd)})` : ''}
</div>
<div>Gas used: {transaction.gas_used != null ? transaction.gas_used.toLocaleString() : 'Unavailable'}</div>
<div>Utilization: {gasUtilization != null ? `${gasUtilization}%` : 'Unavailable'}</div>
</div>
@@ -283,7 +401,12 @@ export default function TransactionDetailPage() {
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Value Movement</div>
<div className="mt-3 space-y-2 text-sm text-gray-700 dark:text-gray-300">
<div>Native value: {formatWeiAsEth(transaction.value)}</div>
<div>
Native value: {formatWeiAsEth(transaction.value)}
{nativeValueUsd != null ? ` (${formatUsd(nativeValueUsd)})` : ''}
</div>
<div>Transfer-time native price: {historicalNativePrice?.priceUsd != null ? `${formatUsd(historicalNativePrice.priceUsd)} per ${nativeAssetSymbol}` : 'Unavailable'}</div>
<div>Pricing source: {formatHistoricalPriceSource(historicalNativePrice?.source)}</div>
<div>Token transfers: {tokenTransferCount.toLocaleString()}</div>
<div>Internal calls: {internalCallCount.toLocaleString()}</div>
</div>
@@ -368,8 +491,16 @@ export default function TransactionDetailPage() {
</Link>
</DetailRow>
)}
<DetailRow label="Value">{formatWeiAsEth(transaction.value)}</DetailRow>
{transaction.fee && <DetailRow label="Fee">{formatWeiAsEth(transaction.fee)}</DetailRow>}
<DetailRow label="Value">
{formatWeiAsEth(transaction.value)}
{nativeValueUsd != null ? ` (${formatUsd(nativeValueUsd)})` : ''}
</DetailRow>
{transaction.fee && (
<DetailRow label="Fee">
{formatWeiAsEth(transaction.fee)}
{nativeFeeUsd != null ? ` (${formatUsd(nativeFeeUsd)})` : ''}
</DetailRow>
)}
<DetailRow label="Gas Price">
{transaction.gas_price != null ? `${transaction.gas_price / 1e9} Gwei` : 'N/A'}
</DetailRow>

View File

@@ -67,9 +67,57 @@ describe('contractsApi', () => {
expect(result.data?.optimization_runs).toBe(200)
expect(result.data?.constructor_arguments?.endsWith('...')).toBe(true)
expect(result.data?.abi).toContain('"symbol"')
expect(result.data?.abi_full).toContain('"symbol"')
expect(result.data?.source_files).toEqual([{ path: 'MockToken.sol', content: 'contract MockToken {}' }])
expect(result.data?.read_methods.map((method) => method.signature)).toContain('symbol()')
})
it('extracts Etherscan-style multi-file verified sources', async () => {
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({
has_custom_methods_read: true,
has_custom_methods_write: true,
implementations: [],
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
status: '1',
message: 'OK',
result: '[]',
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
status: '1',
message: 'OK',
result: [
{
ContractName: 'RWAToken',
SourceCode:
'{{"language":"Solidity","sources":{"contracts/RWAToken.sol":{"content":"contract RWAToken {}"},"contracts/interfaces/IRWA.sol":{"content":"interface IRWA {}"}},"settings":{}}}',
},
],
}),
}),
)
const result = await contractsApi.getProfileSafe('0xcontract')
expect(result.ok).toBe(true)
expect(result.data?.source_files).toEqual([
{ path: 'contracts/RWAToken.sol', content: 'contract RWAToken {}' },
{ path: 'contracts/interfaces/IRWA.sol', content: 'interface IRWA {}' },
])
})
it('calls a simple zero-arg read method through public RPC', async () => {
vi.stubGlobal(
'fetch',

View File

@@ -18,6 +18,11 @@ export interface ContractMethodExecutionResult {
value: string
}
export interface ContractSourceFile {
path: string
content: string
}
export interface ContractProfile {
has_custom_methods_read: boolean
has_custom_methods_write: boolean
@@ -36,7 +41,10 @@ export interface ContractProfile {
license_type?: string
constructor_arguments?: string
abi?: string
abi_full?: string
source_code_full?: string
source_code_preview?: string
source_files: ContractSourceFile[]
source_status_text?: string
read_methods: ContractMethod[]
write_methods: ContractMethod[]
@@ -63,6 +71,7 @@ interface ContractCompatibilityAbiResponse {
interface ContractCompatibilitySourceRecord {
Address?: string
ContractName?: string
FileName?: string
CompilerVersion?: string
OptimizationUsed?: string | number
Runs?: string | number
@@ -111,6 +120,47 @@ function normalizeNumber(value: string | number | null | undefined): number | un
return undefined
}
function displaySourcePath(record: ContractCompatibilitySourceRecord | undefined): string {
const fileName = record?.FileName?.trim()
if (fileName) return fileName
const contractName = record?.ContractName?.trim()
if (contractName) return contractName.endsWith('.sol') ? contractName : `${contractName}.sol`
return 'Contract.sol'
}
function parseSourceFiles(sourceCode: string | undefined, record?: ContractCompatibilitySourceRecord): ContractSourceFile[] {
const trimmed = sourceCode?.trim()
if (!trimmed) return []
const candidates = [trimmed]
if (trimmed.startsWith('{{') && trimmed.endsWith('}}')) {
candidates.push(trimmed.slice(1, -1))
}
for (const candidate of candidates) {
try {
const parsed = JSON.parse(candidate) as {
sources?: Record<string, { content?: string } | string>
}
if (parsed && typeof parsed === 'object' && parsed.sources && typeof parsed.sources === 'object') {
return Object.entries(parsed.sources)
.map(([path, value]) => ({
path,
content: typeof value === 'string' ? value : value?.content || '',
}))
.filter((file) => file.content.trim().length > 0)
}
} catch {}
}
return [
{
path: displaySourcePath(record),
content: trimmed,
},
]
}
function parseABI(abiString?: string): ContractMethod[] {
if (!abiString) return []
try {
@@ -359,6 +409,7 @@ export const contractsApi = {
? sourceRecord.ABI
: undefined
const sourceCode = sourceRecord?.SourceCode
const sourceFiles = parseSourceFiles(sourceCode, sourceRecord)
const parsedMethods = parseABI(abiString)
const sourceVerified = Boolean(
abiString ||
@@ -391,7 +442,10 @@ export const contractsApi = {
license_type: sourceRecord?.LicenseType || undefined,
constructor_arguments: truncateHex(sourceRecord?.ConstructorArguments, 90),
abi: truncateText(abiString, 1200),
abi_full: abiString,
source_code_full: sourceCode,
source_code_preview: truncateText(sourceCode, 1200),
source_files: sourceFiles,
source_status_text: sourceStatusText || undefined,
read_methods: parsedMethods.filter(isReadMethod),
write_methods: parsedMethods.filter((method) => !isReadMethod(method)),

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import { estimateNativeUsdValue, estimateTokenUsdValue, getNativeAssetDescriptor } from './nativeAssetPricing'
describe('nativeAssetPricing', () => {
it('resolves the chain 138 native asset descriptor', () => {
expect(getNativeAssetDescriptor(138)).toEqual({
symbol: 'ETH',
pricingAddress: '0xc02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
})
})
it('estimates USD values from wei using the live asset price', () => {
expect(estimateNativeUsdValue('970000000000000', 2490)).toBe('2.4153')
expect(estimateNativeUsdValue('1000000000000000000', 2490)).toBe('2490')
})
it('returns undefined when pricing inputs are unavailable', () => {
expect(estimateNativeUsdValue(undefined, 2490)).toBeUndefined()
expect(estimateNativeUsdValue('970000000000000', undefined)).toBeUndefined()
expect(estimateNativeUsdValue('not-a-number', 2490)).toBeUndefined()
})
it('estimates token USD values using token decimals', () => {
expect(estimateTokenUsdValue('1000000', 6, 1)).toBe('1')
expect(estimateTokenUsdValue('250000000', 8, 2)).toBe('5')
})
it('preserves precision for large raw balances', () => {
expect(estimateNativeUsdValue('123456789012345678901234567890', 2316.7203872128002)).toBeTruthy()
})
})

View File

@@ -0,0 +1,99 @@
import { tokenAggregationApi, type TokenAggregationTokenSnapshot } from './tokenAggregation'
interface NativeAssetDescriptor {
symbol: string
pricingAddress: string
}
const NATIVE_ASSET_BY_CHAIN_ID: Record<number, NativeAssetDescriptor> = {
138: {
symbol: 'ETH',
pricingAddress: '0xc02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
},
}
export function getNativeAssetDescriptor(chainId: number): NativeAssetDescriptor {
return NATIVE_ASSET_BY_CHAIN_ID[chainId] || { symbol: 'ETH', pricingAddress: NATIVE_ASSET_BY_CHAIN_ID[138].pricingAddress }
}
export async function getNativeAssetMarketSafe(
chainId: number,
): Promise<{ ok: boolean; data: TokenAggregationTokenSnapshot | null }> {
const descriptor = getNativeAssetDescriptor(chainId)
return tokenAggregationApi.getTokenSafe(chainId, descriptor.pricingAddress)
}
export async function getNativeAssetPriceAtSafe(
chainId: number,
timestamp: string,
): Promise<{ ok: boolean; data: Awaited<ReturnType<typeof tokenAggregationApi.getPriceAtSafe>>['data'] }> {
const descriptor = getNativeAssetDescriptor(chainId)
return tokenAggregationApi.getPriceAtSafe(chainId, descriptor.pricingAddress, timestamp)
}
function decimalToScaledInteger(value: number, scale: number): { scaled: bigint; scale: bigint } | null {
if (!Number.isFinite(value)) {
return null
}
const normalized = value.toFixed(scale)
const negative = normalized.startsWith('-')
const unsigned = negative ? normalized.slice(1) : normalized
const [whole, fraction = ''] = unsigned.split('.')
try {
const scaled = BigInt(whole + fraction.padEnd(scale, '0'))
return {
scaled: negative ? -scaled : scaled,
scale: 10n ** BigInt(scale),
}
} catch {
return null
}
}
function formatScaledUsd(
rawAmount: string,
tokenDecimals: number,
priceUsd: number,
priceScale = 8,
outputScale = 6,
): string | undefined {
if (!rawAmount || !Number.isFinite(priceUsd) || tokenDecimals < 0) {
return undefined
}
try {
const amount = BigInt(rawAmount)
const parsedPrice = decimalToScaledInteger(priceUsd, priceScale)
if (!parsedPrice) {
return undefined
}
const numerator = amount * parsedPrice.scaled * (10n ** BigInt(outputScale))
const denominator = (10n ** BigInt(tokenDecimals)) * parsedPrice.scale
const rounded = (numerator + (denominator / 2n)) / denominator
const divisor = 10n ** BigInt(outputScale)
const whole = rounded / divisor
const fraction = (rounded % divisor).toString().padStart(outputScale, '0').replace(/0+$/, '')
return fraction ? `${whole.toString()}.${fraction}` : whole.toString()
} catch {
return undefined
}
}
export function estimateNativeUsdValue(
valueWei: string | null | undefined,
priceUsd: number | undefined,
): string | undefined {
return valueWei && priceUsd != null ? formatScaledUsd(valueWei, 18, priceUsd) : undefined
}
export function estimateTokenUsdValue(
rawAmount: string | null | undefined,
decimals: number,
priceUsd: number | undefined,
): string | undefined {
return rawAmount && priceUsd != null ? formatScaledUsd(rawAmount, decimals, priceUsd) : undefined
}

View File

@@ -0,0 +1,159 @@
import { resolveExplorerApiBase } from '../../../libs/frontend-api-client/api-base'
export interface TokenAggregationMarketSnapshot {
priceUsd?: number
volume24h?: number
liquidityUsd?: number
lastUpdated?: string | null
}
export interface TokenAggregationTokenSnapshot {
chainId: number
address: string
name?: string
symbol?: string
decimals?: number
totalSupply?: string
market?: TokenAggregationMarketSnapshot | null
}
export interface TokenAggregationHistoricalPriceSnapshot {
chainId: number
tokenAddress: string
requestedTimestamp: string
effectiveTimestamp?: string
priceUsd?: number
source?: string
}
interface RawTokenAggregationTokenResponse {
token?: {
chainId?: number | string | null
address?: string | null
name?: string | null
symbol?: string | null
decimals?: number | string | null
totalSupply?: string | null
market?: {
priceUsd?: number | string | null
volume24h?: number | string | null
liquidityUsd?: number | string | null
lastUpdated?: string | null
} | null
} | null
}
interface RawTokenAggregationHistoricalPriceResponse {
chainId?: number | string | null
tokenAddress?: string | null
requestedTimestamp?: string | null
effectiveTimestamp?: string | null
priceUsd?: number | string | null
source?: string | null
}
function toNumber(value: number | string | null | undefined): number | undefined {
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 undefined
}
function normalizeTokenSnapshot(raw: RawTokenAggregationTokenResponse): TokenAggregationTokenSnapshot | null {
const token = raw.token
if (!token?.address) {
return null
}
return {
chainId: toNumber(token.chainId) ?? 138,
address: token.address,
name: token.name || undefined,
symbol: token.symbol || undefined,
decimals: toNumber(token.decimals),
totalSupply: token.totalSupply || undefined,
market: token.market
? {
priceUsd: toNumber(token.market.priceUsd),
volume24h: toNumber(token.market.volume24h),
liquidityUsd: toNumber(token.market.liquidityUsd),
lastUpdated: token.market.lastUpdated || null,
}
: null,
}
}
function normalizeHistoricalPriceSnapshot(
raw: RawTokenAggregationHistoricalPriceResponse,
): TokenAggregationHistoricalPriceSnapshot | null {
if (!raw.tokenAddress || !raw.requestedTimestamp) {
return null
}
return {
chainId: toNumber(raw.chainId) ?? 138,
tokenAddress: raw.tokenAddress,
requestedTimestamp: raw.requestedTimestamp,
effectiveTimestamp: raw.effectiveTimestamp || undefined,
priceUsd: toNumber(raw.priceUsd),
source: raw.source || undefined,
}
}
function getTokenAggregationBase(): string {
return `${resolveExplorerApiBase()}/token-aggregation/api/v1`
}
export const tokenAggregationApi = {
getTokenSafe: async (chainId: number, address: string): Promise<{ ok: boolean; data: TokenAggregationTokenSnapshot | null }> => {
try {
const response = await fetch(`${getTokenAggregationBase()}/tokens/${address}?chainId=${chainId}`)
if (!response.ok) {
return { ok: false, data: null }
}
const raw = (await response.json()) as RawTokenAggregationTokenResponse
return { ok: true, data: normalizeTokenSnapshot(raw) }
} catch {
return { ok: false, data: null }
}
},
getTokensByAddressSafe: async (
chainId: number,
addresses: string[],
): Promise<{ ok: boolean; data: TokenAggregationTokenSnapshot[] }> => {
const uniqueAddresses = [...new Set(addresses.map((address) => address.trim()).filter(Boolean))]
if (uniqueAddresses.length === 0) {
return { ok: true, data: [] }
}
const results = await Promise.all(uniqueAddresses.map((address) => tokenAggregationApi.getTokenSafe(chainId, address)))
const data = results
.filter((result): result is { ok: true; data: TokenAggregationTokenSnapshot | null } => result.ok)
.map((result) => result.data)
.filter((snapshot): snapshot is TokenAggregationTokenSnapshot => Boolean(snapshot?.address))
return { ok: data.length > 0, data }
},
getPriceAtSafe: async (
chainId: number,
address: string,
timestamp: string,
): Promise<{ ok: boolean; data: TokenAggregationHistoricalPriceSnapshot | null }> => {
try {
const response = await fetch(
`${getTokenAggregationBase()}/tokens/${address}/price-at?chainId=${chainId}&timestamp=${encodeURIComponent(timestamp)}`
)
if (!response.ok) {
return { ok: false, data: null }
}
const raw = (await response.json()) as RawTokenAggregationHistoricalPriceResponse
return { ok: true, data: normalizeHistoricalPriceSnapshot(raw) }
} catch {
return { ok: false, data: null }
}
},
}

View File

@@ -20,6 +20,25 @@ describe('tokensApi', () => {
total_supply: '1000',
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
token: {
chainId: 138,
address: '0xtoken',
symbol: 'cUSDT',
name: 'Tether USD (Compliant)',
decimals: 6,
totalSupply: '1000',
market: {
priceUsd: 1,
volume24h: 2500,
liquidityUsd: 500000,
lastUpdated: '2026-04-26T01:00:00.000Z',
},
},
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
@@ -61,6 +80,10 @@ describe('tokensApi', () => {
expect(token.ok).toBe(true)
expect(token.data?.symbol).toBe('cUSDT')
expect(token.data?.exchange_rate).toBe(1)
expect(token.data?.volume_24h).toBe(2500)
expect(token.data?.liquidity_usd).toBe(500000)
expect(token.data?.price_source).toBe('token-aggregation')
expect(holders.data[0].label).toBe('Treasury')
expect(transfers.data[0].token_symbol).toBe('cUSDT')
})

View File

@@ -1,6 +1,7 @@
import { fetchBlockscoutJson, normalizeAddressTokenTransfer, type BlockscoutTokenTransfer } from './blockscout'
import { configApi, type TokenListToken } from './config'
import { routesApi, type MissionControlLiquidityPool } from './routes'
import { tokenAggregationApi } from './tokenAggregation'
import type { AddressTokenTransfer } from './addresses'
export interface TokenProfile {
@@ -15,6 +16,9 @@ export interface TokenProfile {
icon_url?: string | null
circulating_market_cap?: string | number | null
volume_24h?: string | number | null
liquidity_usd?: string | number | null
market_updated_at?: string | null
price_source?: 'blockscout' | 'token-aggregation' | 'derived'
}
export interface TokenHolder {
@@ -45,6 +49,9 @@ function normalizeTokenProfile(raw: {
icon_url?: string | null
circulating_market_cap?: string | number | null
volume_24h?: string | number | null
liquidity_usd?: string | number | null
market_updated_at?: string | null
price_source?: 'blockscout' | 'token-aggregation' | 'derived'
}): TokenProfile {
return {
address: raw.address,
@@ -58,9 +65,68 @@ function normalizeTokenProfile(raw: {
icon_url: raw.icon_url ?? null,
circulating_market_cap: raw.circulating_market_cap ?? null,
volume_24h: raw.volume_24h ?? null,
liquidity_usd: raw.liquidity_usd ?? null,
market_updated_at: raw.market_updated_at ?? null,
price_source: raw.price_source || 'blockscout',
}
}
function computeMarketCap(totalSupply: string | undefined, decimals: number, priceUsd: number | undefined): number | null {
if (!totalSupply || priceUsd == null || !Number.isFinite(priceUsd)) {
return null
}
const supplyNumeric = Number(totalSupply)
if (!Number.isFinite(supplyNumeric) || Math.abs(supplyNumeric) > Number.MAX_SAFE_INTEGER) {
return null
}
const normalizedSupply = supplyNumeric / 10 ** decimals
if (!Number.isFinite(normalizedSupply)) {
return null
}
return normalizedSupply * priceUsd
}
function mergeTokenProfileWithAggregation(
blockscoutToken: TokenProfile | null,
aggregationToken: Awaited<ReturnType<typeof tokenAggregationApi.getTokenSafe>>['data'],
): TokenProfile | null {
if (!blockscoutToken && !aggregationToken) {
return null
}
const priceUsd = aggregationToken?.market?.priceUsd
const merged: TokenProfile = {
address: blockscoutToken?.address || aggregationToken?.address || '',
name: blockscoutToken?.name || aggregationToken?.name,
symbol: blockscoutToken?.symbol || aggregationToken?.symbol,
decimals: blockscoutToken?.decimals || aggregationToken?.decimals || 0,
type: blockscoutToken?.type,
total_supply: blockscoutToken?.total_supply || aggregationToken?.totalSupply,
holders: blockscoutToken?.holders,
exchange_rate: priceUsd ?? blockscoutToken?.exchange_rate ?? null,
icon_url: blockscoutToken?.icon_url ?? null,
circulating_market_cap:
blockscoutToken?.circulating_market_cap ??
computeMarketCap(blockscoutToken?.total_supply || aggregationToken?.totalSupply, blockscoutToken?.decimals || aggregationToken?.decimals || 0, priceUsd),
volume_24h: aggregationToken?.market?.volume24h ?? blockscoutToken?.volume_24h ?? null,
liquidity_usd: aggregationToken?.market?.liquidityUsd ?? blockscoutToken?.liquidity_usd ?? null,
market_updated_at: aggregationToken?.market?.lastUpdated ?? blockscoutToken?.market_updated_at ?? null,
price_source:
priceUsd != null
? 'token-aggregation'
: blockscoutToken?.exchange_rate != null
? 'blockscout'
: blockscoutToken?.circulating_market_cap == null && blockscoutToken?.volume_24h == null && priceUsd == null
? 'derived'
: blockscoutToken?.price_source || 'blockscout',
}
return merged.address ? merged : null
}
function normalizeTokenHolder(raw: {
address?: {
hash?: string | null
@@ -94,7 +160,8 @@ async function getTokenListLookup(): Promise<Map<string, TokenListToken>> {
export const tokensApi = {
getSafe: async (address: string): Promise<{ ok: boolean; data: TokenProfile | null }> => {
try {
const raw = await fetchBlockscoutJson<{
const [blockscoutResult, aggregationResult] = await Promise.allSettled([
fetchBlockscoutJson<{
address: string
name?: string | null
symbol?: string | null
@@ -106,8 +173,17 @@ export const tokensApi = {
icon_url?: string | null
circulating_market_cap?: string | number | null
volume_24h?: string | number | null
}>(`/api/v2/tokens/${address}`)
return { ok: true, data: normalizeTokenProfile(raw) }
}>(`/api/v2/tokens/${address}`),
tokenAggregationApi.getTokenSafe(138, address),
])
const blockscoutToken =
blockscoutResult.status === 'fulfilled' ? normalizeTokenProfile(blockscoutResult.value) : null
const aggregationToken =
aggregationResult.status === 'fulfilled' && aggregationResult.value.ok ? aggregationResult.value.data : null
const merged = mergeTokenProfileWithAggregation(blockscoutToken, aggregationToken)
return { ok: merged != null, data: merged }
} catch {
return { ok: false, data: null }
}

View File

@@ -33,7 +33,7 @@ if [ -n "${1:-}" ] && [[ "$1" =~ ^0x[0-9a-fA-F]{64}$ ]]; then
TX_HASH="$1"
echo "=== Checking Transaction: $TX_HASH ==="
echo ""
echo "Explorer URL: $EXPLORER_URL/tx/$TX_HASH"
echo "Explorer URL: $EXPLORER_URL/transactions/$TX_HASH"
echo ""
# Try to get receipt via RPC
@@ -84,7 +84,7 @@ if [ -n "${1:-}" ] && [[ "$1" =~ ^0x[0-9a-fA-F]{64}$ ]]; then
else
echo "⚠ Transaction not found in RPC (may be pending or not yet indexed)"
echo ""
echo "Check on explorer: $EXPLORER_URL/tx/$TX_HASH"
echo "Check on explorer: $EXPLORER_URL/transactions/$TX_HASH"
fi
else
# Check recent transactions for account
@@ -125,6 +125,6 @@ echo ""
echo "For detailed transaction information, please visit:"
echo " Account: $EXPLORER_URL/address/$ACCOUNT"
if [ -n "${TX_HASH:-}" ]; then
echo " Transaction: $EXPLORER_URL/tx/$TX_HASH"
echo " Transaction: $EXPLORER_URL/transactions/$TX_HASH"
fi
echo ""

View File

@@ -9,6 +9,7 @@ set -euo pipefail
VMID="${VMID:-5000}"
FRONTEND_PORT="${FRONTEND_PORT:-3000}"
FORCE_REMOTE_PCT="${FORCE_REMOTE_PCT:-0}"
SERVICE_NAME="solacescanscout-frontend"
APP_ROOT="/opt/solacescanscout/frontend"
PROXMOX_R630_02="${PROXMOX_HOST_R630_02:-192.168.11.12}"
@@ -23,6 +24,7 @@ RELEASE_ID="$(date +%Y%m%d_%H%M%S)"
TMP_DIR="$(mktemp -d)"
ARCHIVE_NAME="solacescanscout-next-${RELEASE_ID}.tar"
BUILD_LOCK_DIR="${FRONTEND_ROOT}/.next-build-lock"
STANDALONE_ROOT="${FRONTEND_ROOT}/.next/standalone"
STATIC_SYNC_FILES=(
"index.html"
"docs.html"
@@ -53,7 +55,7 @@ push_into_vmid() {
local destination_path="$2"
local perms="${3:-0644}"
if [[ -f /proc/1/cgroup ]] && grep -q "lxc" /proc/1/cgroup 2>/dev/null; then
if [[ "$FORCE_REMOTE_PCT" != "1" ]] && [[ -f /proc/1/cgroup ]] && grep -q "lxc" /proc/1/cgroup 2>/dev/null; then
install -D -m "$perms" "$source_path" "$destination_path"
elif command -v pct >/dev/null 2>&1; then
pct push "$VMID" "$source_path" "$destination_path" --perms "$perms"
@@ -68,7 +70,7 @@ push_into_vmid() {
run_in_vmid() {
local command="$1"
if [[ -f /proc/1/cgroup ]] && grep -q "lxc" /proc/1/cgroup 2>/dev/null; then
if [[ "$FORCE_REMOTE_PCT" != "1" ]] && [[ -f /proc/1/cgroup ]] && grep -q "lxc" /proc/1/cgroup 2>/dev/null; then
bash -lc "$command"
elif command -v pct >/dev/null 2>&1; then
pct exec "$VMID" -- bash -lc "$command"
@@ -109,16 +111,33 @@ if [[ "${SKIP_BUILD:-0}" != "1" ]]; then
echo ""
fi
if [[ ! -f "${FRONTEND_ROOT}/.next/standalone/server.js" ]]; then
APP_RELATIVE_DIR="."
APP_SERVER_PATH="${STANDALONE_ROOT}/server.js"
if [[ ! -f "${APP_SERVER_PATH}" ]]; then
ALT_SERVER_PATH="$(find "${STANDALONE_ROOT}" \
-path '*/node_modules' -prune -o \
-path '*/server.js' -print | head -n 1 || true)"
if [[ -n "${ALT_SERVER_PATH}" ]]; then
APP_SERVER_PATH="${ALT_SERVER_PATH}"
APP_RELATIVE_DIR="$(dirname "${ALT_SERVER_PATH#${STANDALONE_ROOT}/}")"
fi
fi
if [[ ! -f "${APP_SERVER_PATH}" ]]; then
echo "Missing standalone server build. Run \`npm run build\` in ${FRONTEND_ROOT} first." >&2
exit 1
fi
STAGE_DIR="${TMP_DIR}/stage"
mkdir -p "${STAGE_DIR}/.next"
cp -R "${FRONTEND_ROOT}/.next/standalone/." "$STAGE_DIR/"
cp -R "${FRONTEND_ROOT}/.next/static" "${STAGE_DIR}/.next/static"
cp -R "${FRONTEND_ROOT}/public" "${STAGE_DIR}/public"
APP_STAGE_DIR="${STAGE_DIR}"
if [[ "${APP_RELATIVE_DIR}" != "." ]]; then
APP_STAGE_DIR="${STAGE_DIR}/${APP_RELATIVE_DIR}"
fi
mkdir -p "${APP_STAGE_DIR}/.next"
cp -R "${STANDALONE_ROOT}/." "$STAGE_DIR/"
cp -R "${FRONTEND_ROOT}/.next/static" "${APP_STAGE_DIR}/.next/static"
cp -R "${FRONTEND_ROOT}/public" "${APP_STAGE_DIR}/public"
tar -C "$STAGE_DIR" -cf "${TMP_DIR}/${ARCHIVE_NAME}" .
cp "$SERVICE_TEMPLATE" "${TMP_DIR}/${SERVICE_NAME}.service"

View File

@@ -13,9 +13,15 @@ if [ "$EUID" -ne 0 ]; then
exit 1
fi
DB_USER="explorer"
DB_PASSWORD="***REDACTED-LEGACY-PW***"
DB_NAME="explorer"
DB_USER="${DB_USER:-explorer}"
DB_NAME="${DB_NAME:-explorer}"
if [ -z "${DB_PASSWORD:-}" ]; then
echo "ERROR: DB_PASSWORD environment variable must be set before running this script." >&2
echo "Generate a strong value (e.g. openssl rand -base64 32) and export it:" >&2
echo " export DB_PASSWORD='<strong random password>'" >&2
echo " sudo -E bash scripts/setup-database.sh" >&2
exit 1
fi
echo "Creating database user: $DB_USER"
echo "Creating database: $DB_NAME"

View File

@@ -271,7 +271,7 @@ if echo "$SEND_TX" | grep -qE "transactionHash"; then
log_info " Recipient: $DEPLOYER"
log_info ""
log_info "You can monitor the transaction at:"
log_info " https://explorer.d-bis.org/tx/$TX_HASH"
log_info " https://explorer.d-bis.org/transactions/$TX_HASH"
log_info ""
log_success "Process completed successfully!"
log_info ""