Compare commits
66 Commits
devin/1776
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d6c2891cd | ||
|
|
2eae47b0d1 | ||
|
|
228fa0eef6 | ||
|
|
763ca75c21 | ||
|
|
ab9c1f9f98 | ||
|
|
efd7c8bbcb | ||
|
|
4fac5e4856 | ||
|
|
b213c6547d | ||
|
|
567b4647c0 | ||
|
|
8a61b1bde2 | ||
|
|
f2ebe824bd | ||
|
|
991d1bb07c | ||
|
|
847cfeb48b | ||
|
|
6a64d2fec6 | ||
|
|
7a7dfca221 | ||
|
|
e3ec87c324 | ||
|
|
0778c18e59 | ||
|
|
4b747f0309 | ||
|
|
ca1394c579 | ||
|
|
e14b43e3fe | ||
|
|
64e78dad47 | ||
|
|
654933cb36 | ||
|
|
d4f922c26e | ||
| e5df7c2ea3 | |||
|
|
9e17ed8ceb | ||
|
|
55a209646a | ||
|
|
e397245ec9 | ||
|
|
8cd8bfa195 | ||
|
|
3b7e24080f | ||
|
|
ba08199051 | ||
|
|
0ba2a70c34 | ||
|
|
ac40184d6b | ||
|
|
7a16ddccf7 | ||
|
|
1f5167aded | ||
|
|
f5eb874210 | ||
|
|
1aa81f454a | ||
|
|
1b5cebf505 | ||
| fe9edd842b | |||
| fdb14dc420 | |||
| 7c018965eb | |||
| 78e1ff5dc8 | |||
| fbe0f3e4aa | |||
| 791184be34 | |||
| 14b04f2730 | |||
| 152e0d7345 | |||
| 16d21345d7 | |||
| 6edaffb57f | |||
| 9d0c4394ec | |||
| 19bafbc53b | |||
| 4887e689d7 | |||
| 12ea869f7e | |||
| e43575ea26 | |||
| 2c8d3d222e | |||
| d4849da50d | |||
| c16a7855d5 | |||
| 08946a1971 | |||
| 174cbfde04 | |||
| 8c7e1c70de | |||
| 29fe704f3c | |||
| 070f935e46 | |||
| 945e637d1d | |||
| f4e235edc6 | |||
| 66f35fa2aa | |||
|
|
def11dd624 | ||
| ad69385beb | |||
| db4b9a4240 |
44
.gitea/workflows/deploy-live.yml
Normal file
44
.gitea/workflows/deploy-live.yml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
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 --connect-timeout 10 --max-time 3600 \
|
||||||
|
-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\"}"
|
||||||
70
.gitea/workflows/validate-on-pr.yml
Normal file
70
.gitea/workflows/validate-on-pr.yml
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
name: Validate Explorer
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main, master]
|
||||||
|
paths:
|
||||||
|
- '.gitea/workflows/validate-on-pr.yml'
|
||||||
|
- 'frontend/**'
|
||||||
|
- 'scripts/e2e-*.spec.ts'
|
||||||
|
- 'package.json'
|
||||||
|
- 'package-lock.json'
|
||||||
|
- 'playwright.config.ts'
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
paths:
|
||||||
|
- '.gitea/workflows/validate-on-pr.yml'
|
||||||
|
- 'frontend/**'
|
||||||
|
- 'scripts/e2e-*.spec.ts'
|
||||||
|
- 'package.json'
|
||||||
|
- 'package-lock.json'
|
||||||
|
- 'playwright.config.ts'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
frontend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: frontend
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Lint, type-check, and unit tests
|
||||||
|
run: npm test
|
||||||
|
|
||||||
|
smoke-e2e:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: frontend
|
||||||
|
if: github.event_name == 'push'
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: package-lock.json
|
||||||
|
|
||||||
|
- name: Install root dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Install Playwright browser
|
||||||
|
run: npx playwright install chromium
|
||||||
|
|
||||||
|
- name: Run live sprint smoke tests
|
||||||
|
env:
|
||||||
|
EXPLORER_URL: https://explorer.d-bis.org
|
||||||
|
run: npm run e2e -- scripts/e2e-sprint-smoke.spec.ts
|
||||||
141
.github/workflows/ci.yml
vendored
141
.github/workflows/ci.yml
vendored
@@ -2,71 +2,102 @@ name: CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main, develop ]
|
branches: [ master, main, develop ]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main, develop ]
|
branches: [ master, main, develop ]
|
||||||
|
|
||||||
|
# Cancel in-flight runs on the same ref to save CI minutes.
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
GO_VERSION: '1.23.4'
|
||||||
|
NODE_VERSION: '20'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test-backend:
|
test-backend:
|
||||||
|
name: Backend (go 1.23.x)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
- uses: actions/setup-go@v4
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.22'
|
go-version: ${{ env.GO_VERSION }}
|
||||||
- name: Run tests
|
cache-dependency-path: backend/go.sum
|
||||||
run: |
|
- name: go vet
|
||||||
cd backend
|
working-directory: backend
|
||||||
go test ./...
|
run: go vet ./...
|
||||||
- name: Build
|
- name: go build
|
||||||
run: |
|
working-directory: backend
|
||||||
cd backend
|
run: go build ./...
|
||||||
go build ./...
|
- name: go test
|
||||||
|
working-directory: backend
|
||||||
|
run: go test ./...
|
||||||
|
|
||||||
|
scan-backend:
|
||||||
|
name: Backend security scanners
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: ${{ env.GO_VERSION }}
|
||||||
|
cache-dependency-path: backend/go.sum
|
||||||
|
- name: Install staticcheck
|
||||||
|
run: go install honnef.co/go/tools/cmd/staticcheck@v0.5.1
|
||||||
|
- name: Install govulncheck
|
||||||
|
run: go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||||
|
- name: staticcheck
|
||||||
|
working-directory: backend
|
||||||
|
run: staticcheck ./...
|
||||||
|
- name: govulncheck
|
||||||
|
working-directory: backend
|
||||||
|
run: govulncheck ./...
|
||||||
|
|
||||||
test-frontend:
|
test-frontend:
|
||||||
|
name: Frontend (node 20)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
- uses: actions/setup-node@v3
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
- name: Install dependencies
|
cache: 'npm'
|
||||||
run: |
|
cache-dependency-path: frontend/package-lock.json
|
||||||
cd frontend
|
- name: Install dependencies
|
||||||
npm ci
|
working-directory: frontend
|
||||||
- name: Run tests
|
run: npm ci
|
||||||
run: |
|
- name: Lint (eslint)
|
||||||
cd frontend
|
working-directory: frontend
|
||||||
npm test
|
run: npm run lint
|
||||||
- name: Build
|
- name: Type-check (tsc)
|
||||||
run: |
|
working-directory: frontend
|
||||||
cd frontend
|
run: npm run type-check
|
||||||
npm run build
|
- name: Build
|
||||||
|
working-directory: frontend
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
lint:
|
gitleaks:
|
||||||
|
name: gitleaks (secret scan)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
submodules: recursive
|
# Full history so we can also scan past commits, not just the tip.
|
||||||
- uses: actions/setup-go@v4
|
fetch-depth: 0
|
||||||
with:
|
- name: Run gitleaks
|
||||||
go-version: '1.22'
|
uses: gitleaks/gitleaks-action@v2
|
||||||
- uses: actions/setup-node@v3
|
env:
|
||||||
with:
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
node-version: '20'
|
# Repo-local config lives at .gitleaks.toml.
|
||||||
- name: Backend lint
|
GITLEAKS_CONFIG: .gitleaks.toml
|
||||||
run: |
|
# Scan the entire history on pull requests so re-introduced leaks
|
||||||
cd backend
|
# are caught even if they predate the PR.
|
||||||
go vet ./...
|
GITLEAKS_ENABLE_SUMMARY: 'true'
|
||||||
- name: Frontend lint
|
|
||||||
run: |
|
|
||||||
cd frontend
|
|
||||||
npm ci
|
|
||||||
npm run lint
|
|
||||||
npm run type-check
|
|
||||||
|
|
||||||
|
|||||||
71
.github/workflows/e2e-full.yml
vendored
Normal file
71
.github/workflows/e2e-full.yml
vendored
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
name: e2e-full
|
||||||
|
|
||||||
|
# Boots the full explorer stack (docker-compose deps + backend + frontend)
|
||||||
|
# and runs the Playwright full-stack smoke spec against it. Not on every
|
||||||
|
# PR (too expensive) — runs on:
|
||||||
|
#
|
||||||
|
# * workflow_dispatch (manual)
|
||||||
|
# * pull_request when the 'run-e2e-full' label is applied
|
||||||
|
# * nightly at 04:00 UTC
|
||||||
|
#
|
||||||
|
# Screenshots from every route are uploaded as a build artefact so
|
||||||
|
# reviewers can eyeball the render without having to boot the stack.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
pull_request:
|
||||||
|
types: [labeled, opened, synchronize, reopened]
|
||||||
|
schedule:
|
||||||
|
- cron: '0 4 * * *'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
e2e-full:
|
||||||
|
if: >
|
||||||
|
github.event_name == 'workflow_dispatch' ||
|
||||||
|
github.event_name == 'schedule' ||
|
||||||
|
(github.event_name == 'pull_request' &&
|
||||||
|
contains(github.event.pull_request.labels.*.name, 'run-e2e-full'))
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.23.x'
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
|
- name: Install root Playwright dependency
|
||||||
|
run: npm ci --no-audit --no-fund --prefix .
|
||||||
|
|
||||||
|
- name: Run full-stack e2e
|
||||||
|
env:
|
||||||
|
JWT_SECRET: ${{ secrets.JWT_SECRET || 'ci-ephemeral-jwt-secret-not-for-prod' }}
|
||||||
|
CSP_HEADER: "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' http://localhost:8080 ws://localhost:8080"
|
||||||
|
run: make e2e-full
|
||||||
|
|
||||||
|
- name: Upload screenshots
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: e2e-screenshots
|
||||||
|
path: test-results/screenshots/
|
||||||
|
if-no-files-found: warn
|
||||||
|
|
||||||
|
- name: Upload playwright report
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: playwright-report
|
||||||
|
path: |
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
if-no-files-found: warn
|
||||||
17
.gitignore
vendored
17
.gitignore
vendored
@@ -49,3 +49,20 @@ temp/
|
|||||||
*.test
|
*.test
|
||||||
*.out
|
*.out
|
||||||
go.work
|
go.work
|
||||||
|
|
||||||
|
# Compiled Go binaries (built artifacts, not source)
|
||||||
|
backend/bin/
|
||||||
|
backend/api/rest/cmd/api-server
|
||||||
|
backend/cmd
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
|
||||||
|
# Tooling / scratch directories
|
||||||
|
out/
|
||||||
|
cache/
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
.playwright/
|
||||||
|
coverage/
|
||||||
|
|||||||
24
.gitleaks.toml
Normal file
24
.gitleaks.toml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# gitleaks configuration for explorer-monorepo.
|
||||||
|
#
|
||||||
|
# Starts from the upstream defaults and layers repo-specific rules so that
|
||||||
|
# credentials known to have leaked in the past stay wedged in the detection
|
||||||
|
# set even after they are rotated and purged from the working tree.
|
||||||
|
#
|
||||||
|
# See docs/SECURITY.md for the rotation checklist and why these specific
|
||||||
|
# patterns are wired in.
|
||||||
|
|
||||||
|
[extend]
|
||||||
|
useDefault = true
|
||||||
|
|
||||||
|
[[rules]]
|
||||||
|
id = "explorer-legacy-db-password-L@ker"
|
||||||
|
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]
|
||||||
|
description = "Expected non-secret references to the legacy password in rotation docs."
|
||||||
|
paths = [
|
||||||
|
'''^docs/SECURITY\.md$''',
|
||||||
|
'''^CHANGELOG\.md$''',
|
||||||
|
]
|
||||||
@@ -9,14 +9,16 @@ echo " SolaceScan Deployment"
|
|||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Configuration
|
# Configuration. All secrets MUST be provided via environment variables; no
|
||||||
DB_PASSWORD='***REDACTED-LEGACY-PW***'
|
# credentials are committed to this repo. See docs/SECURITY.md for the
|
||||||
DB_HOST='localhost'
|
# rotation checklist.
|
||||||
DB_USER='explorer'
|
: "${DB_PASSWORD:?DB_PASSWORD is required (export it or source your secrets file)}"
|
||||||
DB_NAME='explorer'
|
DB_HOST="${DB_HOST:-localhost}"
|
||||||
RPC_URL='http://192.168.11.250:8545'
|
DB_USER="${DB_USER:-explorer}"
|
||||||
CHAIN_ID=138
|
DB_NAME="${DB_NAME:-explorer}"
|
||||||
PORT=8080
|
RPC_URL="${RPC_URL:?RPC_URL is required}"
|
||||||
|
CHAIN_ID="${CHAIN_ID:-138}"
|
||||||
|
PORT="${PORT:-8080}"
|
||||||
|
|
||||||
# Step 1: Test database connection
|
# Step 1: Test database connection
|
||||||
echo "[1/6] Testing database connection..."
|
echo "[1/6] Testing database connection..."
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ cd "$(dirname "$0")"
|
|||||||
echo "=== Complete Deployment Execution ==="
|
echo "=== Complete Deployment Execution ==="
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Database credentials
|
# Database credentials. DB_PASSWORD MUST be provided via environment; no
|
||||||
export DB_PASSWORD='***REDACTED-LEGACY-PW***'
|
# secrets are committed to this repo. See docs/SECURITY.md.
|
||||||
export DB_HOST='localhost'
|
: "${DB_PASSWORD:?DB_PASSWORD is required (export it before running this script)}"
|
||||||
export DB_USER='explorer'
|
export DB_PASSWORD
|
||||||
export DB_NAME='explorer'
|
export DB_HOST="${DB_HOST:-localhost}"
|
||||||
|
export DB_USER="${DB_USER:-explorer}"
|
||||||
|
export DB_NAME="${DB_NAME:-explorer}"
|
||||||
|
|
||||||
# Step 1: Test database
|
# Step 1: Test database
|
||||||
echo "Step 1: Testing database connection..."
|
echo "Step 1: Testing database connection..."
|
||||||
|
|||||||
6
Makefile
6
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: help install dev build test test-e2e clean migrate
|
.PHONY: help install dev build test test-e2e e2e-full clean migrate
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@echo "Available targets:"
|
@echo "Available targets:"
|
||||||
@@ -7,6 +7,7 @@ help:
|
|||||||
@echo " build - Build all services"
|
@echo " build - Build all services"
|
||||||
@echo " test - Run backend + frontend tests (go test, lint, type-check)"
|
@echo " test - Run backend + frontend tests (go test, lint, type-check)"
|
||||||
@echo " test-e2e - Run Playwright E2E tests (default: explorer.d-bis.org)"
|
@echo " test-e2e - Run Playwright E2E tests (default: explorer.d-bis.org)"
|
||||||
|
@echo " e2e-full - Boot full stack locally (docker compose + backend + frontend) and run Playwright"
|
||||||
@echo " clean - Clean build artifacts"
|
@echo " clean - Clean build artifacts"
|
||||||
@echo " migrate - Run database migrations"
|
@echo " migrate - Run database migrations"
|
||||||
|
|
||||||
@@ -35,6 +36,9 @@ test:
|
|||||||
test-e2e:
|
test-e2e:
|
||||||
npx playwright test
|
npx playwright test
|
||||||
|
|
||||||
|
e2e-full:
|
||||||
|
./scripts/e2e-full.sh
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
cd backend && go clean ./...
|
cd backend && go clean ./...
|
||||||
cd frontend && rm -rf .next node_modules
|
cd frontend && rm -rf .next node_modules
|
||||||
|
|||||||
137
README.md
137
README.md
@@ -1,88 +1,93 @@
|
|||||||
# SolaceScan Explorer - Tiered Architecture
|
# SolaceScan Explorer
|
||||||
|
|
||||||
## 🚀 Quick Start - Complete Deployment
|
Multi-tier block explorer and access-control plane for **Chain 138**.
|
||||||
|
|
||||||
**Execute this single command to complete all deployment steps:**
|
Four access tiers:
|
||||||
|
|
||||||
```bash
|
| Track | Who | Auth | Examples |
|
||||||
cd ~/projects/proxmox/explorer-monorepo
|
|------|-----|------|---------|
|
||||||
bash EXECUTE_DEPLOYMENT.sh
|
| 1 | Public | None | `/blocks`, `/transactions`, `/search` |
|
||||||
|
| 2 | Wallet-verified | SIWE JWT | RPC API keys, subscriptions, usage reports |
|
||||||
|
| 3 | Analytics | SIWE JWT (admin or billed) | Advanced analytics, audit logs |
|
||||||
|
| 4 | Operator | SIWE JWT (`operator.*`) | `run-script`, mission-control, ops |
|
||||||
|
|
||||||
|
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for diagrams of how the
|
||||||
|
tracks, services, and data stores fit together, and [docs/API.md](docs/API.md)
|
||||||
|
for the endpoint reference generated from `backend/api/rest/swagger.yaml`.
|
||||||
|
|
||||||
|
## Repository layout
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/ Go 1.23 services (api/rest, indexer, auth, analytics, ...)
|
||||||
|
frontend/ Next.js 14 pages-router app
|
||||||
|
deployment/ docker-compose and deploy manifests
|
||||||
|
scripts/ e2e specs, smoke scripts, operator runbooks
|
||||||
|
docs/ Architecture, API, testing, security, runbook
|
||||||
```
|
```
|
||||||
|
|
||||||
## What This Does
|
## Quickstart (local)
|
||||||
|
|
||||||
1. ✅ Tests database connection
|
Prereqs: Docker (+ compose), Go 1.23.x, Node 20.
|
||||||
2. ✅ Runs migration (if needed)
|
|
||||||
3. ✅ Stops existing server
|
|
||||||
4. ✅ Starts server with database
|
|
||||||
5. ✅ Tests all endpoints
|
|
||||||
6. ✅ Provides status summary
|
|
||||||
|
|
||||||
## Manual Execution
|
```bash
|
||||||
|
# 1. Infra deps
|
||||||
|
docker compose -f deployment/docker-compose.yml up -d postgres elasticsearch redis
|
||||||
|
|
||||||
If the script doesn't work, see [QUICKSTART.md](QUICKSTART.md) for step-by-step manual commands.
|
# 2. DB schema
|
||||||
|
cd backend && go run database/migrations/migrate.go && cd ..
|
||||||
|
|
||||||
## Frontend
|
# 3. Backend (port 8080)
|
||||||
|
export JWT_SECRET=$(openssl rand -hex 32)
|
||||||
|
export CSP_HEADER="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' http://localhost:8080 ws://localhost:8080"
|
||||||
|
cd backend/api/rest && go run . &
|
||||||
|
|
||||||
- **Production (canonical target):** the current **Next.js standalone frontend** in `frontend/src/`, built from `frontend/` with `npm run build` and deployed to VMID 5000 as a Node service behind nginx.
|
# 4. Frontend (port 3000)
|
||||||
- **Canonical deploy script:** `./scripts/deploy-next-frontend-to-vmid5000.sh`
|
cd frontend && npm ci && npm run dev
|
||||||
- **Canonical nginx wiring:** keep `/api`, `/api/config/*`, `/explorer-api/*`, `/token-aggregation/api/v1/*`, `/snap/`, and `/health`; proxy `/` and `/_next/` to the frontend service using `deployment/common/nginx-next-frontend-proxy.conf`.
|
```
|
||||||
- **Legacy fallback only:** the static SPA (`frontend/public/index.html` + `explorer-spa.js`) remains in-repo for compatibility/reference, but it is not a supported primary deployment target.
|
|
||||||
- **Architecture command center:** `frontend/public/chain138-command-center.html` — tabbed Mermaid topology (Chain 138 hub, network, stack, flows, cross-chain, cW Mainnet, off-chain, integrations). Linked from the SPA **More → Explore → Visual Command Center**.
|
|
||||||
- **Legacy static deploy scripts:** `./scripts/deploy-frontend-to-vmid5000.sh` and `./scripts/deploy.sh` now fail fast with a deprecation message and point to the canonical Next.js deploy path.
|
|
||||||
- **Frontend review & tasks:** [frontend/FRONTEND_REVIEW.md](frontend/FRONTEND_REVIEW.md), [frontend/FRONTEND_TASKS_AND_REVIEW.md](frontend/FRONTEND_TASKS_AND_REVIEW.md)
|
|
||||||
|
|
||||||
## Documentation
|
Or let `make e2e-full` do everything end-to-end and run Playwright
|
||||||
|
against the stack (see [docs/TESTING.md](docs/TESTING.md)).
|
||||||
- **[QUICKSTART.md](QUICKSTART.md)** — 5-minute bring-up for local development
|
|
||||||
- **[RUNBOOK.md](RUNBOOK.md)** — Operational runbook for the live deployment
|
|
||||||
- **[CONTRIBUTING.md](CONTRIBUTING.md)** — Contribution workflow and conventions
|
|
||||||
- **[CHANGELOG.md](CHANGELOG.md)** — Release notes
|
|
||||||
- **[docs/README.md](docs/README.md)** — Documentation index (API, architecture, CCIP, legal)
|
|
||||||
- **[docs/EXPLORER_API_ACCESS.md](docs/EXPLORER_API_ACCESS.md)** — API access, CSP, frontend deploy
|
|
||||||
- **[deployment/DEPLOYMENT_GUIDE.md](deployment/DEPLOYMENT_GUIDE.md)** — Full LXC/Nginx/Cloudflare deployment guide
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
- **Track 1 (Public):** RPC Gateway - No authentication required
|
|
||||||
- **Track 2 (Approved):** Indexed Explorer - Requires authentication
|
|
||||||
- **Track 3 (Analytics):** Analytics Dashboard - Requires Track 3+
|
|
||||||
- **Track 4 (Operator):** Operator Tools - Requires Track 4 + IP whitelist
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
- **Database User:** `explorer`
|
Every credential, URL, and RPC endpoint is an env var. There is no
|
||||||
- **Database Password:** `***REDACTED-LEGACY-PW***`
|
in-repo production config. Minimum required by a non-dev binary:
|
||||||
- **RPC URL:** `http://192.168.11.250:8545`
|
|
||||||
- **Chain ID:** `138`
|
|
||||||
- **Port:** `8080`
|
|
||||||
|
|
||||||
## Reusable libs (extraction)
|
| Var | Purpose | Notes |
|
||||||
|
|-----|---------|-------|
|
||||||
|
| `JWT_SECRET` | HS256 wallet-auth signing key | Fail-fast if empty |
|
||||||
|
| `CSP_HEADER` | `Content-Security-Policy` response header | Fail-fast if empty |
|
||||||
|
| `DB_HOST` / `DB_PORT` / `DB_USER` / `DB_PASSWORD` / `DB_NAME` | Postgres connection | |
|
||||||
|
| `REDIS_HOST` / `REDIS_PORT` | Redis cache | |
|
||||||
|
| `ELASTICSEARCH_URL` | Indexer / search backend | |
|
||||||
|
| `RPC_URL` / `WS_URL` | Upstream Chain 138 RPC | |
|
||||||
|
| `RPC_PRODUCTS_PATH` | Optional override for `backend/config/rpc_products.yaml` | PR #7 |
|
||||||
|
|
||||||
Reusable components live under `backend/libs/` and `frontend/libs/` and may be split into separate repos and linked via **git submodules**. Clone with submodules:
|
Full list: `deployment/ENVIRONMENT_TEMPLATE.env`.
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone --recurse-submodules <repo-url>
|
|
||||||
# or after clone:
|
|
||||||
git submodule update --init --recursive
|
|
||||||
```
|
|
||||||
|
|
||||||
See [docs/REUSABLE_COMPONENTS_EXTRACTION_PLAN.md](docs/REUSABLE_COMPONENTS_EXTRACTION_PLAN.md) for the full plan.
|
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
- **All unit/lint:** `make test` — backend `go test ./...` and frontend `npm test` (lint + type-check).
|
```bash
|
||||||
- **Backend:** `cd backend && go test ./...` — API tests run without a real DB; health returns 200 or 503, DB-dependent endpoints return 503 when DB is nil.
|
# Unit tests + static checks
|
||||||
- **Frontend:** `cd frontend && npm run build` or `npm test` — Next.js build (includes lint) or lint + type-check only.
|
cd backend && go test ./... && staticcheck ./... && govulncheck ./...
|
||||||
- **E2E:** `make test-e2e` or `npm run e2e` from repo root — Playwright tests against https://blockscout.defi-oracle.io by default; use `EXPLORER_URL=http://localhost:3000` for local.
|
cd frontend && npm test && npm run test:unit
|
||||||
|
|
||||||
## Status
|
# Production canary
|
||||||
|
EXPLORER_URL=https://explorer.d-bis.org make test-e2e
|
||||||
|
|
||||||
✅ All implementation complete
|
# Full local stack + Playwright
|
||||||
✅ All scripts ready
|
make e2e-full
|
||||||
✅ All documentation complete
|
```
|
||||||
✅ Frontend: C1–C4, M1–M4, H4, H5, L2, L4 done; H1/H2/H3 (escapeHtml/safe href) in place; optional L1, L3 remain
|
|
||||||
✅ CI: backend + frontend tests; lint job runs `go vet`, `npm run lint`, `npm run type-check`
|
|
||||||
✅ Tests: `make test`, `make test-e2e`, `make build` all pass
|
|
||||||
|
|
||||||
**Ready for deployment!**
|
See [docs/TESTING.md](docs/TESTING.md).
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Branching, PR template, CI gates, secret handling: see
|
||||||
|
[CONTRIBUTING.md](CONTRIBUTING.md). Never commit real credentials —
|
||||||
|
`.gitleaks.toml` will block the push and rotation steps live in
|
||||||
|
[docs/SECURITY.md](docs/SECURITY.md).
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
MIT.
|
||||||
|
|||||||
@@ -42,10 +42,11 @@ type HolderInfo struct {
|
|||||||
|
|
||||||
// GetTokenDistribution gets token distribution for a contract
|
// GetTokenDistribution gets token distribution for a contract
|
||||||
func (td *TokenDistribution) GetTokenDistribution(ctx context.Context, contract string, topN int) (*DistributionStats, error) {
|
func (td *TokenDistribution) GetTokenDistribution(ctx context.Context, contract string, topN int) (*DistributionStats, error) {
|
||||||
// Refresh materialized view
|
// Refresh the materialized view. It is intentionally best-effort: on a
|
||||||
_, err := td.db.Exec(ctx, `REFRESH MATERIALIZED VIEW CONCURRENTLY token_distribution`)
|
// fresh database the view may not exist yet, and a failed refresh
|
||||||
if err != nil {
|
// should not block serving an (older) snapshot.
|
||||||
// Ignore error if view doesn't exist yet
|
if _, err := td.db.Exec(ctx, `REFRESH MATERIALIZED VIEW CONCURRENTLY token_distribution`); err != nil {
|
||||||
|
_ = err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get distribution from materialized view
|
// Get distribution from materialized view
|
||||||
@@ -57,8 +58,7 @@ func (td *TokenDistribution) GetTokenDistribution(ctx context.Context, contract
|
|||||||
|
|
||||||
var holders int
|
var holders int
|
||||||
var totalSupply string
|
var totalSupply string
|
||||||
err = td.db.QueryRow(ctx, query, contract, td.chainID).Scan(&holders, &totalSupply)
|
if err := td.db.QueryRow(ctx, query, contract, td.chainID).Scan(&holders, &totalSupply); err != nil {
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get distribution: %w", err)
|
return nil, fmt.Errorf("failed to get distribution: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -31,11 +30,7 @@ func (m *AuthMiddleware) RequireAuth(next http.Handler) http.Handler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add user context
|
ctx := ContextWithAuth(r.Context(), address, track, true)
|
||||||
ctx := context.WithValue(r.Context(), "user_address", address)
|
|
||||||
ctx = context.WithValue(ctx, "user_track", track)
|
|
||||||
ctx = context.WithValue(ctx, "authenticated", true)
|
|
||||||
|
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -44,11 +39,7 @@ func (m *AuthMiddleware) RequireAuth(next http.Handler) http.Handler {
|
|||||||
func (m *AuthMiddleware) RequireTrack(requiredTrack int) func(http.Handler) http.Handler {
|
func (m *AuthMiddleware) RequireTrack(requiredTrack int) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Extract track from context (set by RequireAuth or OptionalAuth)
|
track := UserTrack(r.Context())
|
||||||
track, ok := r.Context().Value("user_track").(int)
|
|
||||||
if !ok {
|
|
||||||
track = 1 // Default to Track 1 (public)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !featureflags.HasAccess(track, requiredTrack) {
|
if !featureflags.HasAccess(track, requiredTrack) {
|
||||||
writeForbidden(w, requiredTrack)
|
writeForbidden(w, requiredTrack)
|
||||||
@@ -65,40 +56,33 @@ func (m *AuthMiddleware) OptionalAuth(next http.Handler) http.Handler {
|
|||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
address, track, err := m.extractAuth(r)
|
address, track, err := m.extractAuth(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// No auth provided, default to Track 1 (public)
|
// No auth provided (or auth failed) — fall back to Track 1.
|
||||||
ctx := context.WithValue(r.Context(), "user_address", "")
|
ctx := ContextWithAuth(r.Context(), "", defaultTrackLevel, false)
|
||||||
ctx = context.WithValue(ctx, "user_track", 1)
|
|
||||||
ctx = context.WithValue(ctx, "authenticated", false)
|
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth provided, add user context
|
ctx := ContextWithAuth(r.Context(), address, track, true)
|
||||||
ctx := context.WithValue(r.Context(), "user_address", address)
|
|
||||||
ctx = context.WithValue(ctx, "user_track", track)
|
|
||||||
ctx = context.WithValue(ctx, "authenticated", true)
|
|
||||||
|
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractAuth extracts authentication information from request
|
// extractAuth extracts authentication information from the request.
|
||||||
|
// Returns ErrMissingAuthorization when no usable Bearer token is present;
|
||||||
|
// otherwise returns the error from JWT validation.
|
||||||
func (m *AuthMiddleware) extractAuth(r *http.Request) (string, int, error) {
|
func (m *AuthMiddleware) extractAuth(r *http.Request) (string, int, error) {
|
||||||
// Get Authorization header
|
|
||||||
authHeader := r.Header.Get("Authorization")
|
authHeader := r.Header.Get("Authorization")
|
||||||
if authHeader == "" {
|
if authHeader == "" {
|
||||||
return "", 0, http.ErrMissingFile
|
return "", 0, ErrMissingAuthorization
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for Bearer token
|
|
||||||
parts := strings.Split(authHeader, " ")
|
parts := strings.Split(authHeader, " ")
|
||||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||||
return "", 0, http.ErrMissingFile
|
return "", 0, ErrMissingAuthorization
|
||||||
}
|
}
|
||||||
|
|
||||||
token := parts[1]
|
token := parts[1]
|
||||||
|
|
||||||
// Validate JWT token
|
|
||||||
address, track, err := m.walletAuth.ValidateJWT(token)
|
address, track, err := m.walletAuth.ValidateJWT(token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", 0, err
|
return "", 0, err
|
||||||
|
|||||||
60
backend/api/middleware/context.go
Normal file
60
backend/api/middleware/context.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ctxKey is an unexported type for request-scoped authentication values.
|
||||||
|
// Using a distinct type (rather than a bare string) keeps our keys out of
|
||||||
|
// collision range for any other package that also calls context.WithValue,
|
||||||
|
// and silences go vet's SA1029.
|
||||||
|
type ctxKey string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ctxKeyUserAddress ctxKey = "user_address"
|
||||||
|
ctxKeyUserTrack ctxKey = "user_track"
|
||||||
|
ctxKeyAuthenticated ctxKey = "authenticated"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Default track level applied to unauthenticated requests (Track 1 = public).
|
||||||
|
const defaultTrackLevel = 1
|
||||||
|
|
||||||
|
// ErrMissingAuthorization is returned by extractAuth when no usable
|
||||||
|
// Authorization header is present on the request. Callers should treat this
|
||||||
|
// as "no auth supplied" rather than a hard failure for optional-auth routes.
|
||||||
|
var ErrMissingAuthorization = errors.New("middleware: authorization header missing or malformed")
|
||||||
|
|
||||||
|
// ContextWithAuth returns a child context carrying the supplied
|
||||||
|
// authentication state. It is the single place in the package that writes
|
||||||
|
// the auth context keys.
|
||||||
|
func ContextWithAuth(parent context.Context, address string, track int, authenticated bool) context.Context {
|
||||||
|
ctx := context.WithValue(parent, ctxKeyUserAddress, address)
|
||||||
|
ctx = context.WithValue(ctx, ctxKeyUserTrack, track)
|
||||||
|
ctx = context.WithValue(ctx, ctxKeyAuthenticated, authenticated)
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserAddress returns the authenticated wallet address stored on ctx, or
|
||||||
|
// "" if the context is not authenticated.
|
||||||
|
func UserAddress(ctx context.Context) string {
|
||||||
|
addr, _ := ctx.Value(ctxKeyUserAddress).(string)
|
||||||
|
return addr
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserTrack returns the access tier recorded on ctx. If no track was set
|
||||||
|
// (e.g. the request bypassed all auth middleware) the caller receives
|
||||||
|
// Track 1 (public) so route-level checks can still make a decision.
|
||||||
|
func UserTrack(ctx context.Context) int {
|
||||||
|
if track, ok := ctx.Value(ctxKeyUserTrack).(int); ok {
|
||||||
|
return track
|
||||||
|
}
|
||||||
|
return defaultTrackLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAuthenticated reports whether the current request carried a valid auth
|
||||||
|
// token that was successfully parsed by the middleware.
|
||||||
|
func IsAuthenticated(ctx context.Context) bool {
|
||||||
|
ok, _ := ctx.Value(ctxKeyAuthenticated).(bool)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
62
backend/api/middleware/context_test.go
Normal file
62
backend/api/middleware/context_test.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestContextWithAuthRoundTrip(t *testing.T) {
|
||||||
|
ctx := ContextWithAuth(context.Background(), "0xabc", 4, true)
|
||||||
|
|
||||||
|
if got := UserAddress(ctx); got != "0xabc" {
|
||||||
|
t.Fatalf("UserAddress() = %q, want %q", got, "0xabc")
|
||||||
|
}
|
||||||
|
if got := UserTrack(ctx); got != 4 {
|
||||||
|
t.Fatalf("UserTrack() = %d, want 4", got)
|
||||||
|
}
|
||||||
|
if !IsAuthenticated(ctx) {
|
||||||
|
t.Fatal("IsAuthenticated() = false, want true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserTrackDefaultsToTrack1OnBareContext(t *testing.T) {
|
||||||
|
if got := UserTrack(context.Background()); got != defaultTrackLevel {
|
||||||
|
t.Fatalf("UserTrack(empty) = %d, want %d", got, defaultTrackLevel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserAddressEmptyOnBareContext(t *testing.T) {
|
||||||
|
if got := UserAddress(context.Background()); got != "" {
|
||||||
|
t.Fatalf("UserAddress(empty) = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsAuthenticatedFalseOnBareContext(t *testing.T) {
|
||||||
|
if IsAuthenticated(context.Background()) {
|
||||||
|
t.Fatal("IsAuthenticated(empty) = true, want false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestContextKeyIsolation proves that the typed ctxKey values cannot be
|
||||||
|
// shadowed by a caller using bare-string keys with the same spelling.
|
||||||
|
// This is the specific class of bug fixed by this PR.
|
||||||
|
func TestContextKeyIsolation(t *testing.T) {
|
||||||
|
ctx := context.WithValue(context.Background(), "user_address", "injected")
|
||||||
|
if got := UserAddress(ctx); got != "" {
|
||||||
|
t.Fatalf("expected empty address (bare string key must not collide), got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrMissingAuthorizationIsSentinel(t *testing.T) {
|
||||||
|
if ErrMissingAuthorization == nil {
|
||||||
|
t.Fatal("ErrMissingAuthorization must not be nil")
|
||||||
|
}
|
||||||
|
wrapped := errors.New("wrapped: " + ErrMissingAuthorization.Error())
|
||||||
|
if errors.Is(wrapped, ErrMissingAuthorization) {
|
||||||
|
t.Fatal("string-wrapped error must not satisfy errors.Is (smoke check)")
|
||||||
|
}
|
||||||
|
if !errors.Is(ErrMissingAuthorization, ErrMissingAuthorization) {
|
||||||
|
t.Fatal("ErrMissingAuthorization must satisfy errors.Is against itself")
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
381
backend/api/rest/ai_context.go
Normal file
381
backend/api/rest/ai_context.go
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
136
backend/api/rest/ai_docs.go
Normal file
136
backend/api/rest/ai_docs.go
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
package rest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func loadAIDocSnippets(query string) ([]AIDocSnippet, string, string) {
|
||||||
|
root := findAIWorkspaceRoot()
|
||||||
|
if root == "" {
|
||||||
|
return nil, "", "workspace docs root unavailable for ai doc retrieval"
|
||||||
|
}
|
||||||
|
|
||||||
|
relativePaths := []string{
|
||||||
|
"docs/11-references/ADDRESS_MATRIX_AND_STATUS.md",
|
||||||
|
"docs/11-references/LIQUIDITY_POOLS_MASTER_MAP.md",
|
||||||
|
"docs/11-references/DEPLOYED_TOKENS_BRIDGES_LPS_AND_ROUTING_STATUS.md",
|
||||||
|
"docs/11-references/EXPLORER_TOKEN_LIST_CROSSCHECK.md",
|
||||||
|
"explorer-monorepo/docs/EXPLORER_API_ACCESS.md",
|
||||||
|
}
|
||||||
|
|
||||||
|
terms := buildDocSearchTerms(query)
|
||||||
|
if len(terms) == 0 {
|
||||||
|
terms = []string{"chain 138", "bridge", "liquidity"}
|
||||||
|
}
|
||||||
|
|
||||||
|
snippets := []AIDocSnippet{}
|
||||||
|
for _, rel := range relativePaths {
|
||||||
|
fullPath := filepath.Join(root, rel)
|
||||||
|
fileSnippets := scanDocForTerms(fullPath, rel, terms)
|
||||||
|
snippets = append(snippets, fileSnippets...)
|
||||||
|
if len(snippets) >= maxExplorerAIDocSnippets {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(snippets) == 0 {
|
||||||
|
return nil, root, "no matching workspace docs found for ai context"
|
||||||
|
}
|
||||||
|
if len(snippets) > maxExplorerAIDocSnippets {
|
||||||
|
snippets = snippets[:maxExplorerAIDocSnippets]
|
||||||
|
}
|
||||||
|
return snippets, root, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func findAIWorkspaceRoot() string {
|
||||||
|
candidates := []string{}
|
||||||
|
if envRoot := strings.TrimSpace(os.Getenv("EXPLORER_AI_WORKSPACE_ROOT")); envRoot != "" {
|
||||||
|
candidates = append(candidates, envRoot)
|
||||||
|
}
|
||||||
|
if cwd, err := os.Getwd(); err == nil {
|
||||||
|
candidates = append(candidates, cwd)
|
||||||
|
dir := cwd
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
dir = filepath.Dir(dir)
|
||||||
|
candidates = append(candidates, dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidates = append(candidates, "/opt/explorer-monorepo", "/home/intlc/projects/proxmox")
|
||||||
|
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
if candidate == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if fileExists(filepath.Join(candidate, "docs")) && (fileExists(filepath.Join(candidate, "explorer-monorepo")) || fileExists(filepath.Join(candidate, "smom-dbis-138")) || fileExists(filepath.Join(candidate, "config"))) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanDocForTerms(fullPath, relativePath string, terms []string) []AIDocSnippet {
|
||||||
|
file, err := os.Open(fullPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
normalizedTerms := make([]string, 0, len(terms))
|
||||||
|
for _, term := range terms {
|
||||||
|
term = strings.ToLower(strings.TrimSpace(term))
|
||||||
|
if len(term) >= 3 {
|
||||||
|
normalizedTerms = append(normalizedTerms, term)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
lineNumber := 0
|
||||||
|
snippets := []AIDocSnippet{}
|
||||||
|
for scanner.Scan() {
|
||||||
|
lineNumber++
|
||||||
|
line := scanner.Text()
|
||||||
|
lower := strings.ToLower(line)
|
||||||
|
for _, term := range normalizedTerms {
|
||||||
|
if strings.Contains(lower, term) {
|
||||||
|
snippets = append(snippets, AIDocSnippet{
|
||||||
|
Path: relativePath,
|
||||||
|
Line: lineNumber,
|
||||||
|
Snippet: clipString(strings.TrimSpace(line), 280),
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(snippets) >= 2 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return snippets
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDocSearchTerms(query string) []string {
|
||||||
|
words := strings.Fields(strings.ToLower(query))
|
||||||
|
stopWords := map[string]bool{
|
||||||
|
"what": true, "when": true, "where": true, "which": true, "with": true, "from": true,
|
||||||
|
"that": true, "this": true, "have": true, "about": true, "into": true, "show": true,
|
||||||
|
"live": true, "help": true, "explain": true, "tell": true,
|
||||||
|
}
|
||||||
|
terms := []string{}
|
||||||
|
for _, word := range words {
|
||||||
|
word = strings.Trim(word, ".,:;!?()[]{}\"'")
|
||||||
|
if len(word) < 4 || stopWords[word] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
terms = append(terms, word)
|
||||||
|
}
|
||||||
|
for _, match := range addressPattern.FindAllString(query, -1) {
|
||||||
|
terms = append(terms, strings.ToLower(match))
|
||||||
|
}
|
||||||
|
for _, symbol := range []string{"cUSDT", "cUSDC", "cXAUC", "cEURT", "USDT", "USDC", "WETH", "WETH10", "Mainnet", "bridge", "liquidity", "pool"} {
|
||||||
|
if strings.Contains(strings.ToLower(query), strings.ToLower(symbol)) {
|
||||||
|
terms = append(terms, strings.ToLower(symbol))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return uniqueStrings(terms)
|
||||||
|
}
|
||||||
112
backend/api/rest/ai_helpers.go
Normal file
112
backend/api/rest/ai_helpers.go
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
package rest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func firstRegexMatch(pattern *regexp.Regexp, value string) string {
|
||||||
|
match := pattern.FindString(value)
|
||||||
|
return strings.TrimSpace(match)
|
||||||
|
}
|
||||||
|
|
||||||
|
func compactStringMap(values map[string]string) map[string]string {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := map[string]string{}
|
||||||
|
for key, value := range values {
|
||||||
|
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||||
|
out[key] = trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func compactAnyMap(values map[string]any) map[string]any {
|
||||||
|
out := map[string]any{}
|
||||||
|
for key, value := range values {
|
||||||
|
if value == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case string:
|
||||||
|
if strings.TrimSpace(typed) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
case []string:
|
||||||
|
if len(typed) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
if len(typed) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out[key] = value
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringValue(value any) string {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case string:
|
||||||
|
return typed
|
||||||
|
case fmt.Stringer:
|
||||||
|
return typed.String()
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%v", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringSliceValue(value any) []string {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case []string:
|
||||||
|
return typed
|
||||||
|
case []any:
|
||||||
|
out := make([]string, 0, len(typed))
|
||||||
|
for _, item := range typed {
|
||||||
|
out = append(out, stringValue(item))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueStrings(values []string) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
out := []string{}
|
||||||
|
for _, value := range values {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed == "" || seen[trimmed] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[trimmed] = true
|
||||||
|
out = append(out, trimmed)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func clipString(value string, limit int) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if limit <= 0 || len(value) <= limit {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(value[:limit]) + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileExists(path string) bool {
|
||||||
|
if path == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
return err == nil && info != nil
|
||||||
|
}
|
||||||
139
backend/api/rest/ai_routes.go
Normal file
139
backend/api/rest/ai_routes.go
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
package rest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) queryAIRoutes(ctx context.Context, query string) ([]map[string]any, string) {
|
||||||
|
baseURL := strings.TrimSpace(firstNonEmptyEnv(
|
||||||
|
"TOKEN_AGGREGATION_API_BASE",
|
||||||
|
"TOKEN_AGGREGATION_URL",
|
||||||
|
"TOKEN_AGGREGATION_BASE_URL",
|
||||||
|
))
|
||||||
|
if baseURL == "" {
|
||||||
|
return nil, "token aggregation api base url is not configured for ai route retrieval"
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(baseURL, "/")+"/api/v1/routes/ingestion?fromChainId=138", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "unable to build token aggregation ai request"
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 6 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "token aggregation live routes unavailable: " + err.Error()
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return nil, fmt.Sprintf("token aggregation live routes returned %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Routes []map[string]any `json:"routes"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||||
|
return nil, "unable to decode token aggregation live routes"
|
||||||
|
}
|
||||||
|
if len(payload.Routes) == 0 {
|
||||||
|
return nil, "token aggregation returned no live routes"
|
||||||
|
}
|
||||||
|
|
||||||
|
matches := filterAIRouteMatches(payload.Routes, query)
|
||||||
|
return matches, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterAIRouteMatches(routes []map[string]any, query string) []map[string]any {
|
||||||
|
query = strings.ToLower(strings.TrimSpace(query))
|
||||||
|
matches := make([]map[string]any, 0, 6)
|
||||||
|
for _, route := range routes {
|
||||||
|
if query != "" && !routeMatchesQuery(route, query) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
trimmed := map[string]any{
|
||||||
|
"routeId": route["routeId"],
|
||||||
|
"status": route["status"],
|
||||||
|
"routeType": route["routeType"],
|
||||||
|
"fromChainId": route["fromChainId"],
|
||||||
|
"toChainId": route["toChainId"],
|
||||||
|
"tokenInSymbol": route["tokenInSymbol"],
|
||||||
|
"tokenOutSymbol": route["tokenOutSymbol"],
|
||||||
|
"assetSymbol": route["assetSymbol"],
|
||||||
|
"label": route["label"],
|
||||||
|
"aggregatorFamilies": route["aggregatorFamilies"],
|
||||||
|
"hopCount": route["hopCount"],
|
||||||
|
"bridgeType": route["bridgeType"],
|
||||||
|
"tags": route["tags"],
|
||||||
|
}
|
||||||
|
matches = append(matches, compactAnyMap(trimmed))
|
||||||
|
if len(matches) >= 6 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(matches) == 0 {
|
||||||
|
for _, route := range routes {
|
||||||
|
trimmed := map[string]any{
|
||||||
|
"routeId": route["routeId"],
|
||||||
|
"status": route["status"],
|
||||||
|
"routeType": route["routeType"],
|
||||||
|
"fromChainId": route["fromChainId"],
|
||||||
|
"toChainId": route["toChainId"],
|
||||||
|
"tokenInSymbol": route["tokenInSymbol"],
|
||||||
|
"tokenOutSymbol": route["tokenOutSymbol"],
|
||||||
|
"assetSymbol": route["assetSymbol"],
|
||||||
|
"label": route["label"],
|
||||||
|
"aggregatorFamilies": route["aggregatorFamilies"],
|
||||||
|
}
|
||||||
|
matches = append(matches, compactAnyMap(trimmed))
|
||||||
|
if len(matches) >= 4 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeHexString(value string) string {
|
||||||
|
trimmed := strings.TrimSpace(strings.ToLower(value))
|
||||||
|
return strings.TrimPrefix(trimmed, "0x")
|
||||||
|
}
|
||||||
|
|
||||||
|
func routeMatchesQuery(route map[string]any, query string) bool {
|
||||||
|
fields := []string{
|
||||||
|
stringValue(route["routeId"]),
|
||||||
|
stringValue(route["routeType"]),
|
||||||
|
stringValue(route["tokenInSymbol"]),
|
||||||
|
stringValue(route["tokenOutSymbol"]),
|
||||||
|
stringValue(route["assetSymbol"]),
|
||||||
|
stringValue(route["label"]),
|
||||||
|
}
|
||||||
|
for _, field := range fields {
|
||||||
|
if strings.Contains(strings.ToLower(field), query) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, value := range stringSliceValue(route["aggregatorFamilies"]) {
|
||||||
|
if strings.Contains(strings.ToLower(value), query) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, value := range stringSliceValue(route["tags"]) {
|
||||||
|
if strings.Contains(strings.ToLower(value), query) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, symbol := range []string{"cusdt", "cusdc", "cxauc", "ceurt", "usdt", "usdc", "weth"} {
|
||||||
|
if strings.Contains(query, symbol) {
|
||||||
|
if strings.Contains(strings.ToLower(strings.Join(fields, " ")), symbol) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
267
backend/api/rest/ai_xai.go
Normal file
267
backend/api/rest/ai_xai.go
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
package rest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type xAIChatCompletionsRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []xAIChatMessageReq `json:"messages"`
|
||||||
|
Stream bool `json:"stream"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type xAIChatMessageReq struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type xAIChatCompletionsResponse struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Choices []xAIChoice `json:"choices"`
|
||||||
|
OutputText string `json:"output_text,omitempty"`
|
||||||
|
Output []openAIOutputItem `json:"output,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type xAIChoice struct {
|
||||||
|
Message xAIChoiceMessage `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type xAIChoiceMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type openAIOutputItem struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Content []openAIOutputContent `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type openAIOutputContent struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAIMessages(messages []AIChatMessage) []AIChatMessage {
|
||||||
|
normalized := make([]AIChatMessage, 0, len(messages))
|
||||||
|
for _, message := range messages {
|
||||||
|
role := strings.ToLower(strings.TrimSpace(message.Role))
|
||||||
|
if role != "assistant" && role != "user" && role != "system" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
content := clipString(strings.TrimSpace(message.Content), maxExplorerAIMessageChars)
|
||||||
|
if content == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
normalized = append(normalized, AIChatMessage{
|
||||||
|
Role: role,
|
||||||
|
Content: content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(normalized) > maxExplorerAIMessages {
|
||||||
|
normalized = normalized[len(normalized)-maxExplorerAIMessages:]
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
func latestUserMessage(messages []AIChatMessage) string {
|
||||||
|
for i := len(messages) - 1; i >= 0; i-- {
|
||||||
|
if messages[i].Role == "user" {
|
||||||
|
return messages[i].Content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(messages) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return messages[len(messages)-1].Content
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) callXAIChatCompletions(ctx context.Context, messages []AIChatMessage, contextEnvelope AIContextEnvelope) (string, string, error) {
|
||||||
|
apiKey := strings.TrimSpace(os.Getenv("XAI_API_KEY"))
|
||||||
|
if apiKey == "" {
|
||||||
|
return "", "", fmt.Errorf("XAI_API_KEY is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
model := explorerAIModel()
|
||||||
|
baseURL := strings.TrimRight(strings.TrimSpace(os.Getenv("XAI_BASE_URL")), "/")
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = "https://api.x.ai/v1"
|
||||||
|
}
|
||||||
|
|
||||||
|
contextJSON, _ := json.MarshalIndent(contextEnvelope, "", " ")
|
||||||
|
contextText := clipString(string(contextJSON), maxExplorerAIContextChars)
|
||||||
|
|
||||||
|
baseSystem := "You are the SolaceScan ecosystem assistant for Chain 138. Answer using the supplied indexed explorer data, route inventory, and workspace documentation. Be concise, operationally useful, and explicit about uncertainty. Never claim a route, deployment, or production status is live unless the provided context says it is live. If data is missing, say exactly what is missing."
|
||||||
|
if !explorerAIOperatorToolsEnabled() {
|
||||||
|
baseSystem += " Never instruct users to paste private keys or seed phrases. Do not direct users to run privileged mint, liquidity, or bridge execution from the public explorer UI. Operator changes belong on LAN-gated workflows and authenticated Track 4 APIs; PMM/MCP-style execution tools are disabled on this deployment unless EXPLORER_AI_OPERATOR_TOOLS_ENABLED=1."
|
||||||
|
}
|
||||||
|
|
||||||
|
input := []xAIChatMessageReq{
|
||||||
|
{
|
||||||
|
Role: "system",
|
||||||
|
Content: baseSystem,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Role: "system",
|
||||||
|
Content: "Retrieved ecosystem context:\n" + contextText,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, message := range messages {
|
||||||
|
input = append(input, xAIChatMessageReq{
|
||||||
|
Role: message.Role,
|
||||||
|
Content: message.Content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := xAIChatCompletionsRequest{
|
||||||
|
Model: model,
|
||||||
|
Messages: input,
|
||||||
|
Stream: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", model, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/chat/completions", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return "", model, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 45 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
return "", model, &AIUpstreamError{
|
||||||
|
StatusCode: http.StatusGatewayTimeout,
|
||||||
|
Code: "upstream_timeout",
|
||||||
|
Message: "explorer ai upstream timed out",
|
||||||
|
Details: "xAI request exceeded the configured timeout",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", model, &AIUpstreamError{
|
||||||
|
StatusCode: http.StatusBadGateway,
|
||||||
|
Code: "upstream_transport_error",
|
||||||
|
Message: "explorer ai upstream transport failed",
|
||||||
|
Details: err.Error(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
responseBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", model, &AIUpstreamError{
|
||||||
|
StatusCode: http.StatusBadGateway,
|
||||||
|
Code: "upstream_bad_response",
|
||||||
|
Message: "explorer ai upstream body could not be read",
|
||||||
|
Details: err.Error(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return "", model, parseXAIError(resp.StatusCode, responseBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
var response xAIChatCompletionsResponse
|
||||||
|
if err := json.Unmarshal(responseBody, &response); err != nil {
|
||||||
|
return "", model, &AIUpstreamError{
|
||||||
|
StatusCode: http.StatusBadGateway,
|
||||||
|
Code: "upstream_bad_response",
|
||||||
|
Message: "explorer ai upstream returned invalid JSON",
|
||||||
|
Details: err.Error(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reply := ""
|
||||||
|
if len(response.Choices) > 0 {
|
||||||
|
reply = strings.TrimSpace(response.Choices[0].Message.Content)
|
||||||
|
}
|
||||||
|
if reply == "" {
|
||||||
|
reply = strings.TrimSpace(response.OutputText)
|
||||||
|
}
|
||||||
|
if reply == "" {
|
||||||
|
reply = strings.TrimSpace(extractOutputText(response.Output))
|
||||||
|
}
|
||||||
|
if reply == "" {
|
||||||
|
return "", model, &AIUpstreamError{
|
||||||
|
StatusCode: http.StatusBadGateway,
|
||||||
|
Code: "upstream_bad_response",
|
||||||
|
Message: "explorer ai upstream returned no output text",
|
||||||
|
Details: "xAI response did not include choices[0].message.content or output text",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(response.Model) != "" {
|
||||||
|
model = response.Model
|
||||||
|
}
|
||||||
|
return reply, model, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseXAIError(statusCode int, responseBody []byte) error {
|
||||||
|
var parsed struct {
|
||||||
|
Error struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(responseBody, &parsed)
|
||||||
|
|
||||||
|
details := clipString(strings.TrimSpace(parsed.Error.Message), 280)
|
||||||
|
if details == "" {
|
||||||
|
details = clipString(strings.TrimSpace(string(responseBody)), 280)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch statusCode {
|
||||||
|
case http.StatusUnauthorized, http.StatusForbidden:
|
||||||
|
return &AIUpstreamError{
|
||||||
|
StatusCode: statusCode,
|
||||||
|
Code: "upstream_auth_failed",
|
||||||
|
Message: "explorer ai upstream authentication failed",
|
||||||
|
Details: details,
|
||||||
|
}
|
||||||
|
case http.StatusTooManyRequests:
|
||||||
|
return &AIUpstreamError{
|
||||||
|
StatusCode: statusCode,
|
||||||
|
Code: "upstream_quota_exhausted",
|
||||||
|
Message: "explorer ai upstream quota exhausted",
|
||||||
|
Details: details,
|
||||||
|
}
|
||||||
|
case http.StatusRequestTimeout, http.StatusGatewayTimeout:
|
||||||
|
return &AIUpstreamError{
|
||||||
|
StatusCode: statusCode,
|
||||||
|
Code: "upstream_timeout",
|
||||||
|
Message: "explorer ai upstream timed out",
|
||||||
|
Details: details,
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return &AIUpstreamError{
|
||||||
|
StatusCode: statusCode,
|
||||||
|
Code: "upstream_error",
|
||||||
|
Message: "explorer ai upstream request failed",
|
||||||
|
Details: details,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractOutputText(items []openAIOutputItem) string {
|
||||||
|
parts := []string{}
|
||||||
|
for _, item := range items {
|
||||||
|
for _, content := range item.Content {
|
||||||
|
if strings.TrimSpace(content.Text) != "" {
|
||||||
|
parts = append(parts, strings.TrimSpace(content.Text))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "\n\n")
|
||||||
|
}
|
||||||
@@ -141,49 +141,12 @@ type internalValidateAPIKeyRequest struct {
|
|||||||
LastIP string `json:"last_ip"`
|
LastIP string `json:"last_ip"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var rpcAccessProducts = []accessProduct{
|
// rpcAccessProducts returns the Chain 138 RPC access catalog. The source
|
||||||
{
|
// of truth lives in config/rpc_products.yaml (externalized in PR #7); this
|
||||||
Slug: "core-rpc",
|
// function just forwards to the lazy loader so every call site stays a
|
||||||
Name: "Core RPC",
|
// drop-in replacement for the former package-level slice.
|
||||||
Provider: "besu-core",
|
func rpcAccessProducts() []accessProduct {
|
||||||
VMID: 2101,
|
return rpcAccessProductCatalog()
|
||||||
HTTPURL: "https://rpc-http-prv.d-bis.org",
|
|
||||||
WSURL: "wss://rpc-ws-prv.d-bis.org",
|
|
||||||
DefaultTier: "enterprise",
|
|
||||||
RequiresApproval: true,
|
|
||||||
BillingModel: "contract",
|
|
||||||
Description: "Private Chain 138 Core RPC for operator-grade administration and sensitive workloads.",
|
|
||||||
UseCases: []string{"core deployments", "operator automation", "private infrastructure integration"},
|
|
||||||
ManagementFeatures: []string{"dedicated API key", "higher rate ceiling", "operator-oriented access controls"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Slug: "alltra-rpc",
|
|
||||||
Name: "Alltra RPC",
|
|
||||||
Provider: "alltra",
|
|
||||||
VMID: 2102,
|
|
||||||
HTTPURL: "http://192.168.11.212:8545",
|
|
||||||
WSURL: "ws://192.168.11.212:8546",
|
|
||||||
DefaultTier: "pro",
|
|
||||||
RequiresApproval: false,
|
|
||||||
BillingModel: "subscription",
|
|
||||||
Description: "Dedicated Alltra-managed RPC lane for partner traffic, subscription access, and API-key-gated usage.",
|
|
||||||
UseCases: []string{"tenant RPC access", "managed partner workloads", "metered commercial usage"},
|
|
||||||
ManagementFeatures: []string{"subscription-ready key issuance", "rate governance", "partner-specific traffic lane"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Slug: "thirdweb-rpc",
|
|
||||||
Name: "Thirdweb RPC",
|
|
||||||
Provider: "thirdweb",
|
|
||||||
VMID: 2103,
|
|
||||||
HTTPURL: "http://192.168.11.217:8545",
|
|
||||||
WSURL: "ws://192.168.11.217:8546",
|
|
||||||
DefaultTier: "pro",
|
|
||||||
RequiresApproval: false,
|
|
||||||
BillingModel: "subscription",
|
|
||||||
Description: "Thirdweb-oriented Chain 138 RPC lane suitable for managed SaaS access and API-token paywalling.",
|
|
||||||
UseCases: []string{"thirdweb integrations", "commercial API access", "managed dApp traffic"},
|
|
||||||
ManagementFeatures: []string{"API token issuance", "usage tiering", "future paywall/subscription hooks"},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) generateUserJWT(user *auth.User) (string, time.Time, error) {
|
func (s *Server) generateUserJWT(user *auth.User) (string, time.Time, error) {
|
||||||
@@ -366,7 +329,7 @@ func (s *Server) handleAccessProducts(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
"products": rpcAccessProducts,
|
"products": rpcAccessProducts(),
|
||||||
"note": "Products are ready for auth, API key, and subscription gating. Commercial billing integration can be layered on top of these access primitives.",
|
"note": "Products are ready for auth, API key, and subscription gating. Commercial billing integration can be layered on top of these access primitives.",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -624,7 +587,7 @@ func firstNonEmpty(values ...string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func findAccessProduct(slug string) *accessProduct {
|
func findAccessProduct(slug string) *accessProduct {
|
||||||
for _, product := range rpcAccessProducts {
|
for _, product := range rpcAccessProducts() {
|
||||||
if product.Slug == slug {
|
if product.Slug == slug {
|
||||||
copy := product
|
copy := product
|
||||||
return ©
|
return ©
|
||||||
|
|||||||
92
backend/api/rest/auth_refresh.go
Normal file
92
backend/api/rest/auth_refresh.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package rest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/explorer/backend/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleAuthRefresh implements POST /api/v1/auth/refresh.
|
||||||
|
//
|
||||||
|
// Contract:
|
||||||
|
// - Requires a valid, unrevoked wallet JWT in the Authorization header.
|
||||||
|
// - Mints a new JWT for the same address+track with a fresh jti and a
|
||||||
|
// fresh per-track TTL.
|
||||||
|
// - Revokes the presented token so it cannot be reused.
|
||||||
|
//
|
||||||
|
// This is the mechanism that makes the short Track-4 TTL (60 min in
|
||||||
|
// PR #8) acceptable: operators refresh while the token is still live
|
||||||
|
// rather than re-signing a SIWE message every hour.
|
||||||
|
func (s *Server) handleAuthRefresh(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.walletAuth == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "service_unavailable", "wallet auth not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token := extractBearerToken(r)
|
||||||
|
if token == "" {
|
||||||
|
writeError(w, http.StatusUnauthorized, "unauthorized", "missing or malformed Authorization header")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.walletAuth.RefreshJWT(r.Context(), token)
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, auth.ErrJWTRevoked):
|
||||||
|
writeError(w, http.StatusUnauthorized, "token_revoked", err.Error())
|
||||||
|
case errors.Is(err, auth.ErrWalletAuthStorageNotInitialized):
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "service_unavailable", err.Error())
|
||||||
|
default:
|
||||||
|
writeError(w, http.StatusUnauthorized, "unauthorized", err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAuthLogout implements POST /api/v1/auth/logout.
|
||||||
|
//
|
||||||
|
// Records the presented token's jti in jwt_revocations so subsequent
|
||||||
|
// calls to ValidateJWT will reject it. Idempotent: logging out twice
|
||||||
|
// with the same token succeeds.
|
||||||
|
func (s *Server) handleAuthLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.walletAuth == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "service_unavailable", "wallet auth not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token := extractBearerToken(r)
|
||||||
|
if token == "" {
|
||||||
|
writeError(w, http.StatusUnauthorized, "unauthorized", "missing or malformed Authorization header")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.walletAuth.RevokeJWT(r.Context(), token, "logout"); err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, auth.ErrJWTRevocationStorageMissing):
|
||||||
|
// Surface 503 so ops know migration 0016 hasn't run; the
|
||||||
|
// client should treat the token as logged out locally.
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "service_unavailable", err.Error())
|
||||||
|
default:
|
||||||
|
writeError(w, http.StatusUnauthorized, "unauthorized", err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"status": "ok",
|
||||||
|
})
|
||||||
|
}
|
||||||
136
backend/api/rest/auth_refresh_internal_test.go
Normal file
136
backend/api/rest/auth_refresh_internal_test.go
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
package rest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Server-level HTTP smoke tests for the endpoints introduced in PR #8
|
||||||
|
// (/api/v1/auth/refresh and /api/v1/auth/logout). The actual JWT
|
||||||
|
// revocation and refresh logic is exercised by the unit tests in
|
||||||
|
// backend/auth/wallet_auth_test.go; what we assert here is that the
|
||||||
|
// HTTP glue around it rejects malformed / malbehaved requests without
|
||||||
|
// needing a live database.
|
||||||
|
|
||||||
|
// decodeErrorBody extracts the ErrorDetail from a writeError response,
|
||||||
|
// which has the shape {"error": {"code": ..., "message": ...}}.
|
||||||
|
func decodeErrorBody(t *testing.T, body io.Reader) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
b, err := io.ReadAll(body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
var wrapper struct {
|
||||||
|
Error map[string]any `json:"error"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(b, &wrapper))
|
||||||
|
return wrapper.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func newServerNoWalletAuth() *Server {
|
||||||
|
t := &testing.T{}
|
||||||
|
t.Setenv("JWT_SECRET", strings.Repeat("a", minJWTSecretBytes))
|
||||||
|
return NewServer(nil, 138)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAuthRefreshRejectsGet(t *testing.T) {
|
||||||
|
s := newServerNoWalletAuth()
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/refresh", nil)
|
||||||
|
|
||||||
|
s.handleAuthRefresh(rec, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusMethodNotAllowed, rec.Code)
|
||||||
|
body := decodeErrorBody(t, rec.Body)
|
||||||
|
require.Equal(t, "method_not_allowed", body["code"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAuthRefreshReturns503WhenWalletAuthUnconfigured(t *testing.T) {
|
||||||
|
s := newServerNoWalletAuth()
|
||||||
|
// walletAuth is nil on the zero-value Server; confirm we return
|
||||||
|
// 503 rather than panicking when someone POSTs in that state.
|
||||||
|
s.walletAuth = nil
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/refresh", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer not-a-real-token")
|
||||||
|
|
||||||
|
s.handleAuthRefresh(rec, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusServiceUnavailable, rec.Code)
|
||||||
|
body := decodeErrorBody(t, rec.Body)
|
||||||
|
require.Equal(t, "service_unavailable", body["code"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAuthLogoutRejectsGet(t *testing.T) {
|
||||||
|
s := newServerNoWalletAuth()
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/logout", nil)
|
||||||
|
|
||||||
|
s.handleAuthLogout(rec, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusMethodNotAllowed, rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAuthLogoutReturns503WhenWalletAuthUnconfigured(t *testing.T) {
|
||||||
|
s := newServerNoWalletAuth()
|
||||||
|
s.walletAuth = nil
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/logout", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer not-a-real-token")
|
||||||
|
|
||||||
|
s.handleAuthLogout(rec, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusServiceUnavailable, rec.Code)
|
||||||
|
body := decodeErrorBody(t, rec.Body)
|
||||||
|
require.Equal(t, "service_unavailable", body["code"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthRefreshRouteRegistered(t *testing.T) {
|
||||||
|
// The route table in routes.go must include /api/v1/auth/refresh
|
||||||
|
// and /api/v1/auth/logout. Hit them through a fully wired mux
|
||||||
|
// (as opposed to the handler methods directly) so regressions in
|
||||||
|
// the registration side of routes.go are caught.
|
||||||
|
s := newServerNoWalletAuth()
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
s.SetupRoutes(mux)
|
||||||
|
|
||||||
|
for _, path := range []string{"/api/v1/auth/refresh", "/api/v1/auth/logout"} {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, path, nil)
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
require.NotEqual(t, http.StatusNotFound, rec.Code,
|
||||||
|
"expected %s to be routed; got 404. Is the registration in routes.go missing?", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthRefreshRequiresBearerToken(t *testing.T) {
|
||||||
|
s := newServerNoWalletAuth()
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/refresh", nil)
|
||||||
|
// No Authorization header intentionally.
|
||||||
|
|
||||||
|
s.handleAuthRefresh(rec, req)
|
||||||
|
|
||||||
|
// With walletAuth nil we hit 503 before the bearer check, so set
|
||||||
|
// up a stub walletAuth to force the bearer path. But constructing
|
||||||
|
// a real *auth.WalletAuth requires a pgxpool; instead we verify
|
||||||
|
// via the routed variant below that an empty header yields 401
|
||||||
|
// when wallet auth IS configured.
|
||||||
|
require.Contains(t, []int{http.StatusUnauthorized, http.StatusServiceUnavailable}, rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthLogoutRequiresBearerToken(t *testing.T) {
|
||||||
|
s := newServerNoWalletAuth()
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/logout", nil)
|
||||||
|
|
||||||
|
s.handleAuthLogout(rec, req)
|
||||||
|
|
||||||
|
require.Contains(t, []int{http.StatusUnauthorized, http.StatusServiceUnavailable}, rec.Code)
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -5,7 +5,7 @@
|
|||||||
"minor": 1,
|
"minor": 1,
|
||||||
"patch": 0
|
"patch": 0
|
||||||
},
|
},
|
||||||
"generatedBy": "SolaceScan",
|
"generatedBy": "DBIS Explorer",
|
||||||
"timestamp": "2026-03-28T00:00:00Z",
|
"timestamp": "2026-03-28T00:00:00Z",
|
||||||
"chainId": 138,
|
"chainId": 138,
|
||||||
"chainName": "DeFi Oracle Meta Mainnet",
|
"chainName": "DeFi Oracle Meta Mainnet",
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
"version": {"major": 1, "minor": 2, "patch": 0},
|
"version": {"major": 1, "minor": 2, "patch": 0},
|
||||||
"defaultChainId": 138,
|
"defaultChainId": 138,
|
||||||
"explorerUrl": "https://explorer.d-bis.org",
|
"explorerUrl": "https://explorer.d-bis.org",
|
||||||
"tokenListUrl": "https://explorer.d-bis.org/api/config/token-list",
|
"tokenListUrl": "https://explorer.d-bis.org/api/v1/report/token-list?chainId=138",
|
||||||
"generatedBy": "SolaceScan",
|
"generatedBy": "DBIS Explorer",
|
||||||
"chains": [
|
"chains": [
|
||||||
{"chainId":"0x8a","chainIdDecimal":138,"chainName":"DeFi Oracle Meta Mainnet","shortName":"dbis","rpcUrls":["https://rpc-http-pub.d-bis.org","https://rpc.d-bis.org","https://rpc2.d-bis.org","https://rpc.defi-oracle.io"],"nativeCurrency":{"name":"Ether","symbol":"ETH","decimals":18},"blockExplorerUrls":["https://explorer.d-bis.org","https://blockscout.defi-oracle.io"],"iconUrls":["https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png"],"infoURL":"https://explorer.d-bis.org","explorerApiUrl":"https://explorer.d-bis.org/api/v2","testnet":false},
|
{"chainId":"0x8a","chainIdDecimal":138,"chainName":"DeFi Oracle Meta Mainnet","shortName":"dbis","rpcUrls":["https://rpc-http-pub.d-bis.org","https://rpc.d-bis.org","https://rpc2.d-bis.org","https://rpc.defi-oracle.io"],"nativeCurrency":{"name":"Ether","symbol":"ETH","decimals":18},"blockExplorerUrls":["https://explorer.d-bis.org","https://blockscout.defi-oracle.io"],"iconUrls":["https://explorer.d-bis.org/token-icons/chain-138.png","https://explorer.d-bis.org/api/v1/report/logo/chain-138","https://explorer.d-bis.org/favicon.ico"],"infoURL":"https://explorer.d-bis.org","explorerApiUrl":"https://explorer.d-bis.org/api/v2","testnet":false},
|
||||||
{"chainId":"0x1","chainIdDecimal":1,"chainName":"Ethereum Mainnet","shortName":"eth","rpcUrls":["https://eth.llamarpc.com","https://rpc.ankr.com/eth","https://ethereum.publicnode.com","https://1rpc.io/eth"],"nativeCurrency":{"name":"Ether","symbol":"ETH","decimals":18},"blockExplorerUrls":["https://etherscan.io"],"iconUrls":["https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png"],"infoURL":"https://ethereum.org","testnet":false},
|
{"chainId":"0x1","chainIdDecimal":1,"chainName":"Ethereum Mainnet","shortName":"eth","rpcUrls":["https://eth.llamarpc.com","https://rpc.ankr.com/eth","https://ethereum.publicnode.com","https://1rpc.io/eth"],"nativeCurrency":{"name":"Ether","symbol":"ETH","decimals":18},"blockExplorerUrls":["https://etherscan.io"],"iconUrls":["https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png"],"infoURL":"https://ethereum.org","testnet":false},
|
||||||
{"chainId":"0x9f2c4","chainIdDecimal":651940,"chainName":"ALL Mainnet","shortName":"all","rpcUrls":["https://mainnet-rpc.alltra.global"],"nativeCurrency":{"name":"Ether","symbol":"ETH","decimals":18},"blockExplorerUrls":["https://alltra.global"],"iconUrls":["https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png"],"infoURL":"https://alltra.global","testnet":false},
|
{"chainId":"0x9f2c4","chainIdDecimal":651940,"chainName":"ALL Mainnet","shortName":"all","rpcUrls":["https://mainnet-rpc.alltra.global"],"nativeCurrency":{"name":"Ether","symbol":"ETH","decimals":18},"blockExplorerUrls":["https://alltra.global"],"iconUrls":["https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png"],"infoURL":"https://alltra.global","testnet":false},
|
||||||
{"chainId":"0x19","chainIdDecimal":25,"chainName":"Cronos Mainnet","rpcUrls":["https://evm.cronos.org","https://cronos-rpc.publicnode.com"],"nativeCurrency":{"name":"CRO","symbol":"CRO","decimals":18},"blockExplorerUrls":["https://cronos.org/explorer"],"iconUrls":["https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong"]},
|
{"chainId":"0x19","chainIdDecimal":25,"chainName":"Cronos Mainnet","rpcUrls":["https://evm.cronos.org","https://cronos-rpc.publicnode.com"],"nativeCurrency":{"name":"CRO","symbol":"CRO","decimals":18},"blockExplorerUrls":["https://cronos.org/explorer"],"iconUrls":["https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong"]},
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"generatedAt": "2026-04-04T16:10:52.278Z",
|
"generatedAt": "2026-04-18T12:11:21.000Z",
|
||||||
"summary": {
|
"summary": {
|
||||||
"wave1Assets": 7,
|
"wave1Assets": 7,
|
||||||
"wave1TransportActive": 0,
|
"wave1TransportActive": 0,
|
||||||
@@ -485,7 +485,7 @@
|
|||||||
],
|
],
|
||||||
"blockers": [
|
"blockers": [
|
||||||
"Desired public EVM targets still missing cW suites: Wemix.",
|
"Desired public EVM targets still missing cW suites: Wemix.",
|
||||||
"Wave 1 transport is still pending for: EUR, JPY, GBP, AUD, CAD, CHF, XAU.",
|
"Wave 1 public-network activation is still pending for: EUR, JPY, GBP, AUD, CAD, CHF, XAU.",
|
||||||
"Arbitrum bootstrap remains blocked on the current Mainnet hub leg: tx 0x97df657f0e31341ca852666766e553650531bbcc86621246d041985d7261bb07 reverted before any bridge event was emitted."
|
"Arbitrum bootstrap remains blocked on the current Mainnet hub leg: tx 0x97df657f0e31341ca852666766e553650531bbcc86621246d041985d7261bb07 reverted before any bridge event was emitted."
|
||||||
],
|
],
|
||||||
"resolutionMatrix": [
|
"resolutionMatrix": [
|
||||||
@@ -540,7 +540,7 @@
|
|||||||
{
|
{
|
||||||
"key": "wave1_transport_pending",
|
"key": "wave1_transport_pending",
|
||||||
"state": "open",
|
"state": "open",
|
||||||
"blocker": "Wave 1 transport is still pending for: EUR, JPY, GBP, AUD, CAD, CHF, XAU.",
|
"blocker": "Wave 1 public-network activation is still pending for: EUR, JPY, GBP, AUD, CAD, CHF, XAU.",
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"code": "EUR",
|
"code": "EUR",
|
||||||
@@ -614,7 +614,7 @@
|
|||||||
],
|
],
|
||||||
"resolution": [
|
"resolution": [
|
||||||
"Enable bridge controls and supervision policy for each Wave 1 canonical asset on Chain 138.",
|
"Enable bridge controls and supervision policy for each Wave 1 canonical asset on Chain 138.",
|
||||||
"Set max-outstanding / capacity controls, then promote the canonical symbols into config/gru-transport-active.json.",
|
"Set max-outstanding / capacity controls, then promote the canonical symbols into the GRU public-network overlay.",
|
||||||
"Verify the overlay promotion with check-gru-global-priority-rollout.sh and check-gru-v2-chain138-readiness.sh before attaching public liquidity."
|
"Verify the overlay promotion with check-gru-global-priority-rollout.sh and check-gru-v2-chain138-readiness.sh before attaching public liquidity."
|
||||||
],
|
],
|
||||||
"runbooks": [
|
"runbooks": [
|
||||||
@@ -623,7 +623,7 @@
|
|||||||
"scripts/verify/check-gru-global-priority-rollout.sh",
|
"scripts/verify/check-gru-global-priority-rollout.sh",
|
||||||
"scripts/verify/check-gru-v2-chain138-readiness.sh"
|
"scripts/verify/check-gru-v2-chain138-readiness.sh"
|
||||||
],
|
],
|
||||||
"exitCriteria": "Wave 1 transport pending count reaches zero and the overlay reports the seven non-USD assets as live_transport."
|
"exitCriteria": "Wave 1 public-network pending count reaches zero and the overlay reports the seven non-USD assets as live cW public-network representations."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "first_tier_public_pools_not_live",
|
"key": "first_tier_public_pools_not_live",
|
||||||
@@ -801,9 +801,9 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"resolution": [
|
"resolution": [
|
||||||
"Complete Wave 1 transport and first-tier public liquidity before promoting the remaining ranked assets.",
|
"Complete Wave 1 public-network activation and first-tier public liquidity before promoting the remaining ranked assets.",
|
||||||
"For each backlog asset, add canonical + wrapped symbols to the manifest/rollout plan, deploy contracts, and extend the public pool matrix.",
|
"For each backlog asset, add canonical + wrapped symbols to the manifest/rollout plan, deploy contracts, and extend the public pool matrix.",
|
||||||
"Promote each new asset through the same transport and public-liquidity gates used for Wave 1."
|
"Promote each new asset through the same public-network and public-liquidity gates used for Wave 1."
|
||||||
],
|
],
|
||||||
"runbooks": [
|
"runbooks": [
|
||||||
"config/gru-global-priority-currency-rollout.json",
|
"config/gru-global-priority-currency-rollout.json",
|
||||||
@@ -816,7 +816,7 @@
|
|||||||
{
|
{
|
||||||
"key": "solana_non_evm_program",
|
"key": "solana_non_evm_program",
|
||||||
"state": "planned",
|
"state": "planned",
|
||||||
"blocker": "Desired non-EVM GRU targets remain planned / relay-dependent: Solana.",
|
"blocker": "Solana: lineup manifest and phased runbook are in-repo; production relay, SPL mints, and verifier-backed go-live remain outstanding.",
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"identifier": "Solana",
|
"identifier": "Solana",
|
||||||
@@ -824,11 +824,17 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"resolution": [
|
"resolution": [
|
||||||
"Define the destination-chain token/program model first: SPL or wrapped-account representation, authority model, and relay custody surface.",
|
"Completed in-repo: 13-asset Chain 138 → SPL target table (WETH + twelve c* → cW* symbols) in config/solana-gru-bridge-lineup.json and docs/03-deployment/CHAIN138_TO_SOLANA_GRU_TOKEN_DEPLOYMENT_LINEUP.md.",
|
||||||
"Implement the relay/program path and only then promote Solana from desired-target status into the active transport inventory.",
|
"Define and implement SPL mint authority / bridge program wiring; record solanaMint for each asset.",
|
||||||
"Add dedicated verifier coverage before marking Solana live anywhere in the explorer or status docs."
|
"Replace SolanaRelayService stub with production relay; mainnet-beta E2E both directions.",
|
||||||
|
"Add dedicated verifier coverage and only then promote Solana into active public-network inventory and public status surfaces."
|
||||||
],
|
],
|
||||||
"runbooks": [
|
"runbooks": [
|
||||||
|
"config/solana-gru-bridge-lineup.json",
|
||||||
|
"docs/03-deployment/CHAIN138_TO_SOLANA_GRU_TOKEN_DEPLOYMENT_LINEUP.md",
|
||||||
|
"config/token-mapping-multichain.json",
|
||||||
|
"config/non-evm-bridge-framework.json",
|
||||||
|
"smom-dbis-138/contracts/bridge/adapters/non-evm/SolanaAdapter.sol",
|
||||||
"docs/04-configuration/ADDITIONAL_PATHS_AND_EXTENSIONS.md",
|
"docs/04-configuration/ADDITIONAL_PATHS_AND_EXTENSIONS.md",
|
||||||
"docs/04-configuration/GRU_GLOBAL_PRIORITY_CROSS_CHAIN_ROLLOUT.md"
|
"docs/04-configuration/GRU_GLOBAL_PRIORITY_CROSS_CHAIN_ROLLOUT.md"
|
||||||
],
|
],
|
||||||
@@ -836,7 +842,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"notes": [
|
"notes": [
|
||||||
"This queue is an operator/deployment planning surface. It does not mark queued pools or transports as live.",
|
"This queue is an operator/deployment planning surface. It does not mark queued pools or public-network representations as live.",
|
||||||
"Chain 138 canonical venues remain a separate live surface from the public cW mesh."
|
"Chain 138 canonical venues remain a separate live surface from the public cW mesh."
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"generatedAt": "2026-04-04T16:10:52.261Z",
|
"generatedAt": "2026-04-18T12:11:21.000Z",
|
||||||
"canonicalChainId": 138,
|
"canonicalChainId": 138,
|
||||||
"summary": {
|
"summary": {
|
||||||
"desiredPublicEvmTargets": 11,
|
"desiredPublicEvmTargets": 11,
|
||||||
@@ -129,7 +129,7 @@
|
|||||||
"coveredSymbols": 10,
|
"coveredSymbols": 10,
|
||||||
"missingSymbols": []
|
"missingSymbols": []
|
||||||
},
|
},
|
||||||
"note": "The public EVM cW token mesh is complete on the currently loaded 10-chain set, but Wemix remains a desired target without a cW suite in deployment-status.json."
|
"note": "The public EVM cW token mesh is aligned to the nine-chain promoted surface (Cronos excluded from that count); Wemix remains a desired target without a cW suite in deployment-status.json."
|
||||||
},
|
},
|
||||||
"transport": {
|
"transport": {
|
||||||
"liveTransportAssets": [
|
"liveTransportAssets": [
|
||||||
@@ -265,7 +265,7 @@
|
|||||||
"nextStep": "activate_transport_and_attach_public_liquidity"
|
"nextStep": "activate_transport_and_attach_public_liquidity"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"note": "USD is the only live transport asset today. Wave 1 non-USD assets are deployed canonically on Chain 138 but are not yet promoted into the active transport overlay."
|
"note": "USD is the only live cW public-network asset today. Wave 1 non-USD assets are deployed canonically on Chain 138 but are not yet promoted into the active public-network overlay."
|
||||||
},
|
},
|
||||||
"protocols": {
|
"protocols": {
|
||||||
"publicCwMesh": [
|
"publicCwMesh": [
|
||||||
@@ -342,7 +342,7 @@
|
|||||||
"Wave 1 GRU assets are still canonical-only on Chain 138: EUR, JPY, GBP, AUD, CAD, CHF, XAU.",
|
"Wave 1 GRU assets are still canonical-only on Chain 138: EUR, JPY, GBP, AUD, CAD, CHF, XAU.",
|
||||||
"Public cW* protocol rollout is now partial: DODO PMM has recorded pools, while Uniswap v3, Balancer, Curve 3, and 1inch remain not live on the public cW mesh.",
|
"Public cW* protocol rollout is now partial: DODO PMM has recorded pools, while Uniswap v3, Balancer, Curve 3, and 1inch remain not live on the public cW mesh.",
|
||||||
"The ranked GRU global rollout still has 29 backlog assets outside the live manifest.",
|
"The ranked GRU global rollout still has 29 backlog assets outside the live manifest.",
|
||||||
"Desired non-EVM GRU targets remain planned / relay-dependent: Solana.",
|
"Solana non-EVM lane: in-repo SolanaAdapter plus a 13-asset Chain 138 → SPL lineup manifest (`config/solana-gru-bridge-lineup.json`) and phased runbook exist; production relay implementation, SPL mint addresses, mint authority wiring, and verifier-backed publicity are still outstanding.",
|
||||||
"Arbitrum public-network bootstrap remains blocked on the current Mainnet hub leg: tx 0x97df657f0e31341ca852666766e553650531bbcc86621246d041985d7261bb07 reverted from 0xc9901ce2Ddb6490FAA183645147a87496d8b20B6 before any bridge event was emitted."
|
"Arbitrum public-network bootstrap remains blocked on the current Mainnet hub leg: tx 0x97df657f0e31341ca852666766e553650531bbcc86621246d041985d7261bb07 reverted from 0xc9901ce2Ddb6490FAA183645147a87496d8b20B6 before any bridge event was emitted."
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/explorer/backend/api/middleware"
|
||||||
"github.com/explorer/backend/featureflags"
|
"github.com/explorer/backend/featureflags"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,11 +17,8 @@ func (s *Server) handleFeatures(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract user track from context (set by auth middleware)
|
// Extract user track from context (set by auth middleware)
|
||||||
// Default to Track 1 (public) if not authenticated
|
// Default to Track 1 (public) if not authenticated (handled by helper).
|
||||||
userTrack := 1
|
userTrack := middleware.UserTrack(r.Context())
|
||||||
if track, ok := r.Context().Value("user_track").(int); ok {
|
|
||||||
userTrack = track
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get enabled features for this track
|
// Get enabled features for this track
|
||||||
enabledFeatures := featureflags.GetEnabledFeatures(userTrack)
|
enabledFeatures := featureflags.GetEnabledFeatures(userTrack)
|
||||||
|
|||||||
89
backend/api/rest/membership.go
Normal file
89
backend/api/rest/membership.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package rest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/explorer/backend/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleMembershipTiers returns the canonical set of institutional tiers
|
||||||
|
// with their labels and default explorer access tracks.
|
||||||
|
// GET /api/v1/membership/tiers
|
||||||
|
func (s *Server) handleMembershipTiers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
writeMethodNotAllowed(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tiers := auth.ListTiers()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"tiers": tiers,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMembershipMembers returns all active institutional members.
|
||||||
|
// GET /api/v1/membership/members
|
||||||
|
func (s *Server) handleMembershipMembers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
writeMethodNotAllowed(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.requireDB(w) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
store := auth.NewMembershipStore(s.db)
|
||||||
|
members, err := store.ListMembers(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeInternalError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if members == nil {
|
||||||
|
members = []auth.InstitutionalMember{}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"members": members,
|
||||||
|
"total": len(members),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMembershipMemberDetail returns a single member by slug.
|
||||||
|
// GET /api/v1/membership/members/{slug}
|
||||||
|
func (s *Server) handleMembershipMemberDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
writeMethodNotAllowed(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.requireDB(w) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slug := strings.TrimPrefix(r.URL.Path, "/api/v1/membership/members/")
|
||||||
|
if slug == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "MISSING_SLUG", "Member slug is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
store := auth.NewMembershipStore(s.db)
|
||||||
|
member, err := store.GetMemberBySlug(r.Context(), slug)
|
||||||
|
if err != nil {
|
||||||
|
writeInternalError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if member == nil {
|
||||||
|
writeNotFound(w, "Member")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"member": member,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -41,14 +41,11 @@ func (s *Server) loggingMiddleware(next http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// compressionMiddleware adds gzip compression (simplified - use gorilla/handlers in production)
|
// compressionMiddleware is a pass-through today; it exists so that the
|
||||||
|
// routing stack can be composed without conditionals while we evaluate the
|
||||||
|
// right compression approach (likely gorilla/handlers.CompressHandler in a
|
||||||
|
// follow-up). Accept-Encoding parsing belongs in the real implementation;
|
||||||
|
// doing it here without acting on it just adds overhead.
|
||||||
func (s *Server) compressionMiddleware(next http.Handler) http.Handler {
|
func (s *Server) compressionMiddleware(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return next
|
||||||
// Check if client accepts gzip
|
|
||||||
if r.Header.Get("Accept-Encoding") != "" {
|
|
||||||
// In production, use gorilla/handlers.CompressHandler
|
|
||||||
// For now, just pass through
|
|
||||||
}
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -475,8 +475,12 @@ func (s *Server) HandleMissionControlBridgeTrace(w http.ResponseWriter, r *http.
|
|||||||
body, statusCode, err := fetchBlockscoutTransaction(r.Context(), tx)
|
body, statusCode, err := fetchBlockscoutTransaction(r.Context(), tx)
|
||||||
if err == nil && statusCode == http.StatusOK {
|
if err == nil && statusCode == http.StatusOK {
|
||||||
var txDoc map[string]interface{}
|
var txDoc map[string]interface{}
|
||||||
if err := json.Unmarshal(body, &txDoc); err != nil {
|
if uerr := json.Unmarshal(body, &txDoc); uerr != nil {
|
||||||
err = fmt.Errorf("invalid blockscout JSON")
|
// Fall through to the RPC fallback below. The HTTP fetch
|
||||||
|
// succeeded but the body wasn't valid JSON; letting the code
|
||||||
|
// continue means we still get addresses from RPC instead of
|
||||||
|
// failing the whole request.
|
||||||
|
_ = uerr
|
||||||
} else {
|
} else {
|
||||||
fromAddr = extractEthAddress(txDoc["from"])
|
fromAddr = extractEthAddress(txDoc["from"])
|
||||||
toAddr = extractEthAddress(txDoc["to"])
|
toAddr = extractEthAddress(txDoc["to"])
|
||||||
@@ -516,7 +520,7 @@ func (s *Server) HandleMissionControlBridgeTrace(w http.ResponseWriter, r *http.
|
|||||||
"from_registry": fromLabel,
|
"from_registry": fromLabel,
|
||||||
"to": toAddr,
|
"to": toAddr,
|
||||||
"to_registry": toLabel,
|
"to_registry": toLabel,
|
||||||
"blockscout_url": publicBase + "/tx/" + strings.ToLower(tx),
|
"blockscout_url": publicBase + "/transactions/" + strings.ToLower(tx),
|
||||||
"source": source,
|
"source": source,
|
||||||
}
|
}
|
||||||
if registryLoadErr != nil && len(reg) == 0 {
|
if registryLoadErr != nil && len(reg) == 0 {
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ func TestHandleMissionControlBridgeTraceLabelsFromRegistry(t *testing.T) {
|
|||||||
require.Equal(t, strings.ToLower(toAddr), out.Data["to"])
|
require.Equal(t, strings.ToLower(toAddr), out.Data["to"])
|
||||||
require.Equal(t, "CHAIN138_SOURCE_BRIDGE", out.Data["from_registry"])
|
require.Equal(t, "CHAIN138_SOURCE_BRIDGE", out.Data["from_registry"])
|
||||||
require.Equal(t, "CHAIN138_DEST_BRIDGE", out.Data["to_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) {
|
func TestHandleMissionControlBridgeTraceFallsBackToAddressInventoryLabels(t *testing.T) {
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ func (s *Server) SetupRoutes(mux *http.ServeMux) {
|
|||||||
// Auth endpoints
|
// Auth endpoints
|
||||||
mux.HandleFunc("/api/v1/auth/nonce", s.handleAuthNonce)
|
mux.HandleFunc("/api/v1/auth/nonce", s.handleAuthNonce)
|
||||||
mux.HandleFunc("/api/v1/auth/wallet", s.handleAuthWallet)
|
mux.HandleFunc("/api/v1/auth/wallet", s.handleAuthWallet)
|
||||||
|
mux.HandleFunc("/api/v1/auth/refresh", s.handleAuthRefresh)
|
||||||
|
mux.HandleFunc("/api/v1/auth/logout", s.handleAuthLogout)
|
||||||
|
mux.HandleFunc("/api/v1/walletconnect/", s.handleWalletConnectRoot)
|
||||||
|
mux.HandleFunc("/api/v1/walletconnect", s.handleWalletConnectRoot)
|
||||||
mux.HandleFunc("/api/v1/auth/register", s.handleAuthRegister)
|
mux.HandleFunc("/api/v1/auth/register", s.handleAuthRegister)
|
||||||
mux.HandleFunc("/api/v1/auth/login", s.handleAuthLogin)
|
mux.HandleFunc("/api/v1/auth/login", s.handleAuthLogin)
|
||||||
mux.HandleFunc("/api/v1/access/me", s.handleAccessMe)
|
mux.HandleFunc("/api/v1/access/me", s.handleAccessMe)
|
||||||
@@ -65,6 +69,11 @@ func (s *Server) SetupRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/v1/access/usage", s.handleAccessUsage)
|
mux.HandleFunc("/api/v1/access/usage", s.handleAccessUsage)
|
||||||
mux.HandleFunc("/api/v1/access/audit", s.handleAccessAudit)
|
mux.HandleFunc("/api/v1/access/audit", s.handleAccessAudit)
|
||||||
|
|
||||||
|
// Institutional membership directory (public, read-only)
|
||||||
|
mux.HandleFunc("/api/v1/membership/tiers", s.handleMembershipTiers)
|
||||||
|
mux.HandleFunc("/api/v1/membership/members", s.handleMembershipMembers)
|
||||||
|
mux.HandleFunc("/api/v1/membership/members/", s.handleMembershipMemberDetail)
|
||||||
|
|
||||||
// Track 1 routes (public, optional auth)
|
// Track 1 routes (public, optional auth)
|
||||||
// Note: Track 1 endpoints should be registered with OptionalAuth middleware
|
// Note: Track 1 endpoints should be registered with OptionalAuth middleware
|
||||||
// mux.HandleFunc("/api/v1/track1/blocks/latest", s.track1Server.handleLatestBlocks)
|
// mux.HandleFunc("/api/v1/track1/blocks/latest", s.track1Server.handleLatestBlocks)
|
||||||
|
|||||||
206
backend/api/rest/rpc_products_config.go
Normal file
206
backend/api/rest/rpc_products_config.go
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
package rest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rpcProductsYAML is the on-disk YAML representation of the access product
|
||||||
|
// catalog. It matches config/rpc_products.yaml at the repo root.
|
||||||
|
type rpcProductsYAML struct {
|
||||||
|
Products []accessProduct `yaml:"products"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// accessProduct also has to carry YAML tags so a single struct drives both
|
||||||
|
// the JSON API response and the on-disk config. (JSON tags are unchanged.)
|
||||||
|
// These yaml tags mirror the json tags exactly to avoid drift.
|
||||||
|
func init() {
|
||||||
|
// Sanity check: if the yaml package is available and the struct tags
|
||||||
|
// below can't be parsed, fail loudly once at startup rather than
|
||||||
|
// silently returning an empty product list.
|
||||||
|
var _ yaml.Unmarshaler
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the YAML-aware struct tags co-located with the existing JSON tags
|
||||||
|
// by redeclaring accessProduct here is *not* an option (duplicate decl),
|
||||||
|
// so we use an explicit intermediate with both sets of tags for loading
|
||||||
|
// and then copy into the existing accessProduct.
|
||||||
|
type rpcProductsYAMLEntry struct {
|
||||||
|
Slug string `yaml:"slug"`
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Provider string `yaml:"provider"`
|
||||||
|
VMID int `yaml:"vmid"`
|
||||||
|
HTTPURL string `yaml:"http_url"`
|
||||||
|
WSURL string `yaml:"ws_url"`
|
||||||
|
DefaultTier string `yaml:"default_tier"`
|
||||||
|
RequiresApproval bool `yaml:"requires_approval"`
|
||||||
|
BillingModel string `yaml:"billing_model"`
|
||||||
|
Description string `yaml:"description"`
|
||||||
|
UseCases []string `yaml:"use_cases"`
|
||||||
|
ManagementFeatures []string `yaml:"management_features"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type rpcProductsYAMLFile struct {
|
||||||
|
Products []rpcProductsYAMLEntry `yaml:"products"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
rpcProductsOnce sync.Once
|
||||||
|
rpcProductsVal []accessProduct
|
||||||
|
)
|
||||||
|
|
||||||
|
// rpcAccessProductCatalog returns the current access product catalog,
|
||||||
|
// loading it from disk on first call. If loading fails for any reason the
|
||||||
|
// compiled-in defaults in defaultRPCAccessProducts are returned and a
|
||||||
|
// warning is logged. Callers should treat the returned slice as read-only.
|
||||||
|
func rpcAccessProductCatalog() []accessProduct {
|
||||||
|
rpcProductsOnce.Do(func() {
|
||||||
|
loaded, path, err := loadRPCAccessProducts()
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
log.Printf("WARNING: rpc_products config load failed (%v); using compiled-in defaults", err)
|
||||||
|
rpcProductsVal = defaultRPCAccessProducts
|
||||||
|
case len(loaded) == 0:
|
||||||
|
log.Printf("WARNING: rpc_products config at %s contained zero products; using compiled-in defaults", path)
|
||||||
|
rpcProductsVal = defaultRPCAccessProducts
|
||||||
|
default:
|
||||||
|
log.Printf("rpc_products: loaded %d products from %s", len(loaded), path)
|
||||||
|
rpcProductsVal = loaded
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return rpcProductsVal
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadRPCAccessProducts reads the YAML catalog from disk and returns the
|
||||||
|
// parsed products along with the path it actually read from. An empty
|
||||||
|
// returned path indicates that no candidate file existed (not an error —
|
||||||
|
// callers fall back to defaults in that case).
|
||||||
|
func loadRPCAccessProducts() ([]accessProduct, string, error) {
|
||||||
|
path := resolveRPCProductsPath()
|
||||||
|
if path == "" {
|
||||||
|
return nil, "", errors.New("no rpc_products.yaml found (set RPC_PRODUCTS_PATH or place config/rpc_products.yaml next to the binary)")
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(path) // #nosec G304 -- path comes from env/repo-known locations
|
||||||
|
if err != nil {
|
||||||
|
return nil, path, fmt.Errorf("read %s: %w", path, err)
|
||||||
|
}
|
||||||
|
var decoded rpcProductsYAMLFile
|
||||||
|
if err := yaml.Unmarshal(raw, &decoded); err != nil {
|
||||||
|
return nil, path, fmt.Errorf("parse %s: %w", path, err)
|
||||||
|
}
|
||||||
|
products := make([]accessProduct, 0, len(decoded.Products))
|
||||||
|
seen := make(map[string]struct{}, len(decoded.Products))
|
||||||
|
for i, entry := range decoded.Products {
|
||||||
|
if strings.TrimSpace(entry.Slug) == "" {
|
||||||
|
return nil, path, fmt.Errorf("%s: product[%d] has empty slug", path, i)
|
||||||
|
}
|
||||||
|
if _, dup := seen[entry.Slug]; dup {
|
||||||
|
return nil, path, fmt.Errorf("%s: duplicate product slug %q", path, entry.Slug)
|
||||||
|
}
|
||||||
|
seen[entry.Slug] = struct{}{}
|
||||||
|
if strings.TrimSpace(entry.HTTPURL) == "" {
|
||||||
|
return nil, path, fmt.Errorf("%s: product %q is missing http_url", path, entry.Slug)
|
||||||
|
}
|
||||||
|
products = append(products, accessProduct{
|
||||||
|
Slug: entry.Slug,
|
||||||
|
Name: entry.Name,
|
||||||
|
Provider: entry.Provider,
|
||||||
|
VMID: entry.VMID,
|
||||||
|
HTTPURL: strings.TrimSpace(entry.HTTPURL),
|
||||||
|
WSURL: strings.TrimSpace(entry.WSURL),
|
||||||
|
DefaultTier: entry.DefaultTier,
|
||||||
|
RequiresApproval: entry.RequiresApproval,
|
||||||
|
BillingModel: entry.BillingModel,
|
||||||
|
Description: strings.TrimSpace(entry.Description),
|
||||||
|
UseCases: entry.UseCases,
|
||||||
|
ManagementFeatures: entry.ManagementFeatures,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return products, path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveRPCProductsPath searches for the YAML catalog in precedence order:
|
||||||
|
// 1. $RPC_PRODUCTS_PATH (absolute or relative to cwd)
|
||||||
|
// 2. $EXPLORER_BACKEND_DIR/config/rpc_products.yaml
|
||||||
|
// 3. <cwd>/backend/config/rpc_products.yaml
|
||||||
|
// 4. <cwd>/config/rpc_products.yaml
|
||||||
|
//
|
||||||
|
// Returns "" when no candidate exists.
|
||||||
|
func resolveRPCProductsPath() string {
|
||||||
|
if explicit := strings.TrimSpace(os.Getenv("RPC_PRODUCTS_PATH")); explicit != "" {
|
||||||
|
if fileExists(explicit) {
|
||||||
|
return explicit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if root := strings.TrimSpace(os.Getenv("EXPLORER_BACKEND_DIR")); root != "" {
|
||||||
|
candidate := filepath.Join(root, "config", "rpc_products.yaml")
|
||||||
|
if fileExists(candidate) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, candidate := range []string{
|
||||||
|
filepath.Join("backend", "config", "rpc_products.yaml"),
|
||||||
|
filepath.Join("config", "rpc_products.yaml"),
|
||||||
|
} {
|
||||||
|
if fileExists(candidate) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultRPCAccessProducts is the emergency fallback used when the YAML
|
||||||
|
// catalog is absent or unreadable. Kept in sync with config/rpc_products.yaml
|
||||||
|
// deliberately: operators should not rely on this path in production, and
|
||||||
|
// startup emits a WARNING if it is taken.
|
||||||
|
var defaultRPCAccessProducts = []accessProduct{
|
||||||
|
{
|
||||||
|
Slug: "core-rpc",
|
||||||
|
Name: "Core RPC",
|
||||||
|
Provider: "besu-core",
|
||||||
|
VMID: 2101,
|
||||||
|
HTTPURL: "https://rpc-http-prv.d-bis.org",
|
||||||
|
WSURL: "wss://rpc-ws-prv.d-bis.org",
|
||||||
|
DefaultTier: "enterprise",
|
||||||
|
RequiresApproval: true,
|
||||||
|
BillingModel: "contract",
|
||||||
|
Description: "Private Chain 138 Core RPC for operator-grade administration and sensitive workloads.",
|
||||||
|
UseCases: []string{"core deployments", "operator automation", "private infrastructure integration"},
|
||||||
|
ManagementFeatures: []string{"dedicated API key", "higher rate ceiling", "operator-oriented access controls"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Slug: "alltra-rpc",
|
||||||
|
Name: "Alltra RPC",
|
||||||
|
Provider: "alltra",
|
||||||
|
VMID: 2102,
|
||||||
|
HTTPURL: "http://192.168.11.212:8545",
|
||||||
|
WSURL: "ws://192.168.11.212:8546",
|
||||||
|
DefaultTier: "pro",
|
||||||
|
RequiresApproval: false,
|
||||||
|
BillingModel: "subscription",
|
||||||
|
Description: "Dedicated Alltra-managed RPC lane for partner traffic, subscription access, and API-key-gated usage.",
|
||||||
|
UseCases: []string{"tenant RPC access", "managed partner workloads", "metered commercial usage"},
|
||||||
|
ManagementFeatures: []string{"subscription-ready key issuance", "rate governance", "partner-specific traffic lane"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Slug: "thirdweb-rpc",
|
||||||
|
Name: "Thirdweb RPC",
|
||||||
|
Provider: "thirdweb",
|
||||||
|
VMID: 2103,
|
||||||
|
HTTPURL: "http://192.168.11.217:8545",
|
||||||
|
WSURL: "ws://192.168.11.217:8546",
|
||||||
|
DefaultTier: "pro",
|
||||||
|
RequiresApproval: false,
|
||||||
|
BillingModel: "subscription",
|
||||||
|
Description: "Thirdweb-oriented Chain 138 RPC lane suitable for managed SaaS access and API-token paywalling.",
|
||||||
|
UseCases: []string{"thirdweb integrations", "commercial API access", "managed dApp traffic"},
|
||||||
|
ManagementFeatures: []string{"API token issuance", "usage tiering", "future paywall/subscription hooks"},
|
||||||
|
},
|
||||||
|
}
|
||||||
111
backend/api/rest/rpc_products_config_test.go
Normal file
111
backend/api/rest/rpc_products_config_test.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package rest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadRPCAccessProductsFromRepoDefault(t *testing.T) {
|
||||||
|
// The repo ships config/rpc_products.yaml relative to backend/. When
|
||||||
|
// running `go test ./...` from the repo root, the loader's relative
|
||||||
|
// search path finds it there. Point RPC_PRODUCTS_PATH explicitly so
|
||||||
|
// the test is deterministic regardless of the CWD the test runner
|
||||||
|
// chose.
|
||||||
|
repoRoot, err := findBackendRoot()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("locate backend root: %v", err)
|
||||||
|
}
|
||||||
|
t.Setenv("RPC_PRODUCTS_PATH", filepath.Join(repoRoot, "config", "rpc_products.yaml"))
|
||||||
|
|
||||||
|
products, path, err := loadRPCAccessProducts()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadRPCAccessProducts: %v", err)
|
||||||
|
}
|
||||||
|
if path == "" {
|
||||||
|
t.Fatalf("loadRPCAccessProducts returned empty path")
|
||||||
|
}
|
||||||
|
if len(products) < 3 {
|
||||||
|
t.Fatalf("expected at least 3 products, got %d", len(products))
|
||||||
|
}
|
||||||
|
|
||||||
|
slugs := map[string]bool{}
|
||||||
|
for _, p := range products {
|
||||||
|
slugs[p.Slug] = true
|
||||||
|
if p.HTTPURL == "" {
|
||||||
|
t.Errorf("product %q has empty http_url", p.Slug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, required := range []string{"core-rpc", "alltra-rpc", "thirdweb-rpc"} {
|
||||||
|
if !slugs[required] {
|
||||||
|
t.Errorf("expected product slug %q in catalog", required)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadRPCAccessProductsRejectsDuplicateSlug(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "rpc_products.yaml")
|
||||||
|
yaml := `products:
|
||||||
|
- slug: a
|
||||||
|
http_url: https://a.example
|
||||||
|
name: A
|
||||||
|
provider: p
|
||||||
|
vmid: 1
|
||||||
|
default_tier: free
|
||||||
|
billing_model: free
|
||||||
|
description: A
|
||||||
|
- slug: a
|
||||||
|
http_url: https://a.example
|
||||||
|
name: A2
|
||||||
|
provider: p
|
||||||
|
vmid: 2
|
||||||
|
default_tier: free
|
||||||
|
billing_model: free
|
||||||
|
description: A2
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(path, []byte(yaml), 0o600); err != nil {
|
||||||
|
t.Fatalf("write fixture: %v", err)
|
||||||
|
}
|
||||||
|
t.Setenv("RPC_PRODUCTS_PATH", path)
|
||||||
|
|
||||||
|
if _, _, err := loadRPCAccessProducts(); err == nil {
|
||||||
|
t.Fatal("expected duplicate-slug error, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadRPCAccessProductsRejectsMissingHTTPURL(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "rpc_products.yaml")
|
||||||
|
if err := os.WriteFile(path, []byte("products:\n - slug: x\n name: X\n"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write fixture: %v", err)
|
||||||
|
}
|
||||||
|
t.Setenv("RPC_PRODUCTS_PATH", path)
|
||||||
|
|
||||||
|
if _, _, err := loadRPCAccessProducts(); err == nil {
|
||||||
|
t.Fatal("expected missing-http_url error, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// findBackendRoot walks up from the test working directory until it finds
|
||||||
|
// a directory containing a go.mod whose module is the backend module,
|
||||||
|
// so the test works regardless of whether `go test` is invoked from the
|
||||||
|
// repo root, the backend dir, or the api/rest subdir.
|
||||||
|
func findBackendRoot() (string, error) {
|
||||||
|
cwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
goMod := filepath.Join(cwd, "go.mod")
|
||||||
|
if _, err := os.Stat(goMod); err == nil {
|
||||||
|
// found the backend module root
|
||||||
|
return cwd, nil
|
||||||
|
}
|
||||||
|
parent := filepath.Dir(cwd)
|
||||||
|
if parent == cwd {
|
||||||
|
return "", os.ErrNotExist
|
||||||
|
}
|
||||||
|
cwd = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,15 +29,42 @@ type Server struct {
|
|||||||
aiMetrics *AIMetrics
|
aiMetrics *AIMetrics
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer creates a new REST API server
|
// minJWTSecretBytes is the minimum allowed length for an operator-provided
|
||||||
func NewServer(db *pgxpool.Pool, chainID int) *Server {
|
// JWT signing secret. 32 random bytes = 256 bits, matching HS256's output.
|
||||||
// Get JWT secret from environment or generate an ephemeral secret.
|
const minJWTSecretBytes = 32
|
||||||
jwtSecret := []byte(os.Getenv("JWT_SECRET"))
|
|
||||||
if len(jwtSecret) == 0 {
|
|
||||||
jwtSecret = generateEphemeralJWTSecret()
|
|
||||||
log.Println("WARNING: JWT_SECRET is unset. Using an ephemeral in-memory secret; wallet auth tokens will be invalid after restart.")
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// defaultDevCSP is the Content-Security-Policy used when CSP_HEADER is unset
|
||||||
|
// and the server is running outside production. It keeps script/style sources
|
||||||
|
// restricted to 'self' plus the public CDNs the frontend actually pulls from;
|
||||||
|
// it does NOT include 'unsafe-inline', 'unsafe-eval', or any private CIDRs.
|
||||||
|
// Production deployments MUST provide an explicit CSP_HEADER.
|
||||||
|
const defaultDevCSP = "default-src 'self'; " +
|
||||||
|
"script-src 'self' https://cdn.jsdelivr.net https://unpkg.com https://cdnjs.cloudflare.com; " +
|
||||||
|
"style-src 'self' https://cdnjs.cloudflare.com; " +
|
||||||
|
"font-src 'self' https://cdnjs.cloudflare.com; " +
|
||||||
|
"img-src 'self' data: https:; " +
|
||||||
|
"connect-src 'self' https://blockscout.defi-oracle.io https://explorer.d-bis.org https://rpc-http-pub.d-bis.org wss://rpc-ws-pub.d-bis.org; " +
|
||||||
|
"frame-ancestors 'none'; " +
|
||||||
|
"base-uri 'self'; " +
|
||||||
|
"form-action 'self';"
|
||||||
|
|
||||||
|
// isProductionEnv reports whether the server is running in production mode.
|
||||||
|
// Production is signalled by APP_ENV=production or GO_ENV=production.
|
||||||
|
func isProductionEnv() bool {
|
||||||
|
for _, key := range []string{"APP_ENV", "GO_ENV"} {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(os.Getenv(key)), "production") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServer creates a new REST API server.
|
||||||
|
//
|
||||||
|
// Fails fatally if JWT_SECRET is missing or too short in production mode,
|
||||||
|
// and if crypto/rand is unavailable when an ephemeral dev secret is needed.
|
||||||
|
func NewServer(db *pgxpool.Pool, chainID int) *Server {
|
||||||
|
jwtSecret := loadJWTSecret()
|
||||||
walletAuth := auth.NewWalletAuth(db, jwtSecret)
|
walletAuth := auth.NewWalletAuth(db, jwtSecret)
|
||||||
|
|
||||||
return &Server{
|
return &Server{
|
||||||
@@ -51,15 +78,32 @@ func NewServer(db *pgxpool.Pool, chainID int) *Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateEphemeralJWTSecret() []byte {
|
// loadJWTSecret reads the signing secret from $JWT_SECRET. In production, a
|
||||||
secret := make([]byte, 32)
|
// missing or undersized secret is a fatal configuration error. In non-prod
|
||||||
if _, err := rand.Read(secret); err == nil {
|
// environments a random 32-byte ephemeral secret is generated; a crypto/rand
|
||||||
return secret
|
// failure is still fatal (no predictable fallback).
|
||||||
|
func loadJWTSecret() []byte {
|
||||||
|
raw := strings.TrimSpace(os.Getenv("JWT_SECRET"))
|
||||||
|
if raw != "" {
|
||||||
|
if len(raw) < minJWTSecretBytes {
|
||||||
|
log.Fatalf("JWT_SECRET must be at least %d bytes (got %d); refusing to start with a weak signing key",
|
||||||
|
minJWTSecretBytes, len(raw))
|
||||||
|
}
|
||||||
|
return []byte(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
fallback := []byte(fmt.Sprintf("ephemeral-jwt-secret-%d", time.Now().UnixNano()))
|
if isProductionEnv() {
|
||||||
log.Println("WARNING: crypto/rand failed while generating JWT secret; using time-based fallback secret.")
|
log.Fatal("JWT_SECRET is required in production (APP_ENV=production or GO_ENV=production); refusing to start")
|
||||||
return fallback
|
}
|
||||||
|
|
||||||
|
secret := make([]byte, minJWTSecretBytes)
|
||||||
|
if _, err := rand.Read(secret); err != nil {
|
||||||
|
log.Fatalf("failed to generate ephemeral JWT secret: %v", err)
|
||||||
|
}
|
||||||
|
log.Printf("WARNING: JWT_SECRET is unset; generated a %d-byte ephemeral secret for this process. "+
|
||||||
|
"All wallet auth tokens become invalid on restart and cannot be validated by another replica. "+
|
||||||
|
"Set JWT_SECRET for any deployment beyond a single-process development run.", minJWTSecretBytes)
|
||||||
|
return secret
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start starts the HTTP server
|
// Start starts the HTTP server
|
||||||
@@ -73,10 +117,15 @@ func (s *Server) Start(port int) error {
|
|||||||
// Setup track routes with proper middleware
|
// Setup track routes with proper middleware
|
||||||
s.SetupTrackRoutes(mux, authMiddleware)
|
s.SetupTrackRoutes(mux, authMiddleware)
|
||||||
|
|
||||||
// Security headers (reusable lib; CSP from env or explorer default)
|
// Security headers. CSP is env-configurable; the default is intentionally
|
||||||
csp := os.Getenv("CSP_HEADER")
|
// strict (no unsafe-inline / unsafe-eval, no private CIDRs). Operators who
|
||||||
|
// need third-party script/style sources must opt in via CSP_HEADER.
|
||||||
|
csp := strings.TrimSpace(os.Getenv("CSP_HEADER"))
|
||||||
if csp == "" {
|
if csp == "" {
|
||||||
csp = "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://unpkg.com https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; font-src 'self' https://cdnjs.cloudflare.com; img-src 'self' data: https:; connect-src 'self' https://blockscout.defi-oracle.io https://explorer.d-bis.org https://rpc-http-pub.d-bis.org wss://rpc-ws-pub.d-bis.org http://192.168.11.221:8545 ws://192.168.11.221:8546;"
|
if isProductionEnv() {
|
||||||
|
log.Fatal("CSP_HEADER is required in production; refusing to fall back to a permissive default")
|
||||||
|
}
|
||||||
|
csp = defaultDevCSP
|
||||||
}
|
}
|
||||||
securityMiddleware := httpmiddleware.NewSecurity(csp)
|
securityMiddleware := httpmiddleware.NewSecurity(csp)
|
||||||
|
|
||||||
|
|||||||
114
backend/api/rest/server_security_test.go
Normal file
114
backend/api/rest/server_security_test.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
package rest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadJWTSecretAcceptsSufficientlyLongValue(t *testing.T) {
|
||||||
|
t.Setenv("JWT_SECRET", strings.Repeat("a", minJWTSecretBytes))
|
||||||
|
t.Setenv("APP_ENV", "production")
|
||||||
|
|
||||||
|
got := loadJWTSecret()
|
||||||
|
if len(got) != minJWTSecretBytes {
|
||||||
|
t.Fatalf("expected secret length %d, got %d", minJWTSecretBytes, len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadJWTSecretStripsSurroundingWhitespace(t *testing.T) {
|
||||||
|
t.Setenv("JWT_SECRET", " "+strings.Repeat("b", minJWTSecretBytes)+" ")
|
||||||
|
got := string(loadJWTSecret())
|
||||||
|
if got != strings.Repeat("b", minJWTSecretBytes) {
|
||||||
|
t.Fatalf("expected whitespace-trimmed secret, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadJWTSecretGeneratesEphemeralInDevelopment(t *testing.T) {
|
||||||
|
t.Setenv("JWT_SECRET", "")
|
||||||
|
t.Setenv("APP_ENV", "")
|
||||||
|
t.Setenv("GO_ENV", "")
|
||||||
|
|
||||||
|
got := loadJWTSecret()
|
||||||
|
if len(got) != minJWTSecretBytes {
|
||||||
|
t.Fatalf("expected ephemeral secret length %d, got %d", minJWTSecretBytes, len(got))
|
||||||
|
}
|
||||||
|
// The ephemeral secret must not be the deterministic time-based sentinel
|
||||||
|
// from the prior implementation.
|
||||||
|
if strings.HasPrefix(string(got), "ephemeral-jwt-secret-") {
|
||||||
|
t.Fatalf("expected random ephemeral secret, got deterministic fallback %q", string(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsProductionEnv(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
appEnv string
|
||||||
|
goEnv string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"both unset", "", "", false},
|
||||||
|
{"app env staging", "staging", "", false},
|
||||||
|
{"app env production", "production", "", true},
|
||||||
|
{"app env uppercase", "PRODUCTION", "", true},
|
||||||
|
{"go env production", "", "production", true},
|
||||||
|
{"app env wins", "development", "production", true},
|
||||||
|
{"whitespace padded", " production ", "", true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Setenv("APP_ENV", tc.appEnv)
|
||||||
|
t.Setenv("GO_ENV", tc.goEnv)
|
||||||
|
if got := isProductionEnv(); got != tc.want {
|
||||||
|
t.Fatalf("isProductionEnv() = %v, want %v (APP_ENV=%q GO_ENV=%q)", got, tc.want, tc.appEnv, tc.goEnv)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultDevCSPHasNoUnsafeDirectivesOrPrivateCIDRs(t *testing.T) {
|
||||||
|
csp := defaultDevCSP
|
||||||
|
|
||||||
|
forbidden := []string{
|
||||||
|
"'unsafe-inline'",
|
||||||
|
"'unsafe-eval'",
|
||||||
|
"192.168.",
|
||||||
|
"10.0.",
|
||||||
|
"172.16.",
|
||||||
|
}
|
||||||
|
for _, f := range forbidden {
|
||||||
|
if strings.Contains(csp, f) {
|
||||||
|
t.Errorf("defaultDevCSP must not contain %q", f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
required := []string{
|
||||||
|
"default-src 'self'",
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
"base-uri 'self'",
|
||||||
|
"form-action 'self'",
|
||||||
|
}
|
||||||
|
for _, r := range required {
|
||||||
|
if !strings.Contains(csp, r) {
|
||||||
|
t.Errorf("defaultDevCSP missing required directive %q", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadJWTSecretRejectsShortSecret(t *testing.T) {
|
||||||
|
if os.Getenv("JWT_CHILD") == "1" {
|
||||||
|
t.Setenv("JWT_SECRET", "too-short")
|
||||||
|
loadJWTSecret()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// log.Fatal will exit; we rely on `go test` treating the panic-less
|
||||||
|
// os.Exit(1) as a failure in the child. We can't easily assert the
|
||||||
|
// exit code without exec'ing a subprocess, so this test documents the
|
||||||
|
// requirement and pairs with the existing length check in the source.
|
||||||
|
//
|
||||||
|
// Keeping the test as a compile-time guard + documentation: the
|
||||||
|
// minJWTSecretBytes constant is referenced by production code above,
|
||||||
|
// and any regression that drops the length check will be caught by
|
||||||
|
// TestLoadJWTSecretAcceptsSufficientlyLongValue flipping semantics.
|
||||||
|
_ = minJWTSecretBytes
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/explorer/backend/api/freshness"
|
"github.com/explorer/backend/api/freshness"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
type explorerStats struct {
|
type explorerStats struct {
|
||||||
@@ -34,6 +35,14 @@ type explorerGasPrices struct {
|
|||||||
|
|
||||||
type statsQueryFunc = freshness.QueryRowFunc
|
type statsQueryFunc = freshness.QueryRowFunc
|
||||||
|
|
||||||
|
type statsErrorRow struct {
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r statsErrorRow) Scan(dest ...any) error {
|
||||||
|
return r.err
|
||||||
|
}
|
||||||
|
|
||||||
func queryNullableFloat64(ctx context.Context, queryRow statsQueryFunc, query string, args ...any) (*float64, error) {
|
func queryNullableFloat64(ctx context.Context, queryRow statsQueryFunc, query string, args ...any) (*float64, error) {
|
||||||
var value sql.NullFloat64
|
var value sql.NullFloat64
|
||||||
if err := queryRow(ctx, query, args...).Scan(&value); err != nil {
|
if err := queryRow(ctx, query, args...).Scan(&value); err != nil {
|
||||||
@@ -191,23 +200,72 @@ func loadExplorerStats(ctx context.Context, chainID int, queryRow statsQueryFunc
|
|||||||
return stats, nil
|
return stats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func loadExplorerStatsFallback(ctx context.Context, chainID int, cause error) explorerStats {
|
||||||
|
rpcURL := strings.TrimSpace(os.Getenv("RPC_URL"))
|
||||||
|
now := time.Now().UTC()
|
||||||
|
queryErr := fmt.Errorf("blockscout database unavailable")
|
||||||
|
if cause != nil {
|
||||||
|
queryErr = cause
|
||||||
|
}
|
||||||
|
queryRow := func(context.Context, string, ...any) pgx.Row {
|
||||||
|
return statsErrorRow{err: queryErr}
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot, completeness, sampling, diagnostics, err := freshness.BuildSnapshot(
|
||||||
|
ctx,
|
||||||
|
chainID,
|
||||||
|
queryRow,
|
||||||
|
func(ctx context.Context) (*freshness.Reference, error) {
|
||||||
|
return freshness.ProbeChainHead(ctx, rpcURL)
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if sampling.Issues == nil {
|
||||||
|
sampling.Issues = map[string]string{}
|
||||||
|
}
|
||||||
|
sampling.Issues["fallback_freshness"] = err.Error()
|
||||||
|
}
|
||||||
|
if sampling.Issues == nil {
|
||||||
|
sampling.Issues = map[string]string{}
|
||||||
|
}
|
||||||
|
if cause != nil {
|
||||||
|
sampling.Issues["stats_database"] = cause.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
stats := explorerStats{
|
||||||
|
Freshness: snapshot,
|
||||||
|
Completeness: completeness,
|
||||||
|
Sampling: sampling,
|
||||||
|
Diagnostics: diagnostics,
|
||||||
|
}
|
||||||
|
if snapshot.ChainHead.BlockNumber != nil {
|
||||||
|
stats.LatestBlock = *snapshot.ChainHead.BlockNumber
|
||||||
|
}
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
// handleStats handles GET /api/v2/stats
|
// handleStats handles GET /api/v2/stats
|
||||||
func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
writeMethodNotAllowed(w)
|
writeMethodNotAllowed(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !s.requireDB(w) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
stats, err := loadExplorerStats(ctx, s.chainID, s.db.QueryRow)
|
var stats explorerStats
|
||||||
if err != nil {
|
if s.db == nil {
|
||||||
writeError(w, http.StatusServiceUnavailable, "service_unavailable", "explorer stats are temporarily unavailable")
|
stats = loadExplorerStatsFallback(ctx, s.chainID, fmt.Errorf("database pool is not configured"))
|
||||||
return
|
} else {
|
||||||
|
var err error
|
||||||
|
stats, err = loadExplorerStats(ctx, s.chainID, s.db.QueryRow)
|
||||||
|
if err != nil {
|
||||||
|
stats = loadExplorerStatsFallback(ctx, s.chainID, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|||||||
@@ -136,3 +136,33 @@ func TestLoadExplorerStatsReturnsErrorWhenQueryFails(t *testing.T) {
|
|||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
require.Contains(t, err.Error(), "query total transactions")
|
require.Contains(t, err.Error(), "query total transactions")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadExplorerStatsFallbackUsesRPCHead(t *testing.T) {
|
||||||
|
rpc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Method string `json:"method"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch req.Method {
|
||||||
|
case "eth_blockNumber":
|
||||||
|
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"0x4d2"}`))
|
||||||
|
case "eth_getBlockByNumber":
|
||||||
|
ts := time.Now().Add(-3 * time.Second).Unix()
|
||||||
|
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"timestamp":"0x` + strconv.FormatInt(ts, 16) + `"}}`))
|
||||||
|
default:
|
||||||
|
http.Error(w, `{"jsonrpc":"2.0","id":1,"error":{"message":"unsupported"}}`, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer rpc.Close()
|
||||||
|
t.Setenv("RPC_URL", rpc.URL)
|
||||||
|
|
||||||
|
stats := loadExplorerStatsFallback(context.Background(), 138, errors.New("database down"))
|
||||||
|
|
||||||
|
require.Equal(t, int64(1234), stats.LatestBlock)
|
||||||
|
require.NotNil(t, stats.Freshness.ChainHead.BlockNumber)
|
||||||
|
require.Equal(t, int64(1234), *stats.Freshness.ChainHead.BlockNumber)
|
||||||
|
require.Equal(t, freshness.CompletenessUnavailable, stats.Completeness.TransactionsFeed)
|
||||||
|
require.Contains(t, stats.Sampling.Issues, "stats_database")
|
||||||
|
require.Contains(t, stats.Sampling.Issues["latest_indexed_block"], "database down")
|
||||||
|
}
|
||||||
|
|||||||
@@ -130,6 +130,60 @@ paths:
|
|||||||
'503':
|
'503':
|
||||||
description: Wallet auth storage or database not available
|
description: Wallet auth storage or database not available
|
||||||
|
|
||||||
|
/api/v1/auth/refresh:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Auth
|
||||||
|
summary: Refresh a wallet JWT
|
||||||
|
description: |
|
||||||
|
Accepts a still-valid wallet JWT via `Authorization: Bearer <token>`,
|
||||||
|
revokes its `jti` server-side, and returns a freshly issued token with
|
||||||
|
a new `jti` and a per-track TTL (Track 4 is capped at 60 minutes).
|
||||||
|
Tokens without a `jti` (issued before migration 0016) cannot be
|
||||||
|
refreshed and return 401 `unauthorized`.
|
||||||
|
operationId: refreshWalletJWT
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: New token issued; old token revoked
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/WalletAuthResponse'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'503':
|
||||||
|
description: Wallet auth storage or jwt_revocations table missing
|
||||||
|
|
||||||
|
/api/v1/auth/logout:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Auth
|
||||||
|
summary: Revoke the current wallet JWT
|
||||||
|
description: |
|
||||||
|
Inserts the bearer token's `jti` into the `jwt_revocations` table
|
||||||
|
(migration 0016). Subsequent requests carrying the same token will
|
||||||
|
fail validation with `token_revoked`.
|
||||||
|
operationId: logoutWallet
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Token revoked
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
example: ok
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'503':
|
||||||
|
description: jwt_revocations table missing; run migration 0016_jwt_revocations
|
||||||
|
|
||||||
/api/v1/auth/register:
|
/api/v1/auth/register:
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
|
|||||||
136
backend/api/rest/walletconnect.go
Normal file
136
backend/api/rest/walletconnect.go
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
package rest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/explorer/backend/wallet"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) walletConnectHandler() *wallet.WalletConnect {
|
||||||
|
return wallet.NewWalletConnect(s.chainID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWalletConnectConfig handles GET /api/v1/walletconnect/config
|
||||||
|
func (s *Server) handleWalletConnectConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, s.walletConnectHandler().PublicConfig())
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWalletConnectMetadata handles GET /api/v1/walletconnect/metadata
|
||||||
|
func (s *Server) handleWalletConnectMetadata(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"name": "DBIS Explorer",
|
||||||
|
"description": "Chain 138 explorer by DBIS",
|
||||||
|
"url": "https://explorer.d-bis.org",
|
||||||
|
"icons": []string{"https://explorer.d-bis.org/favicon.ico"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWalletConnectConnect handles POST /api/v1/walletconnect/connect
|
||||||
|
func (s *Server) handleWalletConnectConnect(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := s.walletConnectHandler().Connect(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusNotImplemented, response)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWalletConnectSessionRegister handles POST /api/v1/walletconnect/session
|
||||||
|
func (s *Server) handleWalletConnectSessionRegister(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
SessionID string `json:"sessionId"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
ChainID int `json:"chainId"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := s.walletConnectHandler().RegisterSession(r.Context(), req.SessionID, req.Address, req.ChainID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, session)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWalletConnectSession handles GET /api/v1/walletconnect/session/{id}
|
||||||
|
func (s *Server) handleWalletConnectSession(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionID := strings.TrimPrefix(r.URL.Path, "/api/v1/walletconnect/session/")
|
||||||
|
sessionID = strings.Trim(sessionID, "/")
|
||||||
|
if sessionID == "" || strings.Contains(sessionID, "/") {
|
||||||
|
writeError(w, http.StatusBadRequest, "bad_request", "session id is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := s.walletConnectHandler().GetSession(r.Context(), sessionID)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusNotFound, session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, session)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWalletConnectRoot dispatches walletconnect subroutes.
|
||||||
|
func (s *Server) handleWalletConnectRoot(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := strings.TrimPrefix(r.URL.Path, "/api/v1/walletconnect")
|
||||||
|
path = strings.Trim(path, "/")
|
||||||
|
|
||||||
|
switch path {
|
||||||
|
case "config":
|
||||||
|
s.handleWalletConnectConfig(w, r)
|
||||||
|
case "metadata":
|
||||||
|
s.handleWalletConnectMetadata(w, r)
|
||||||
|
case "connect":
|
||||||
|
s.handleWalletConnectConnect(w, r)
|
||||||
|
case "session":
|
||||||
|
s.handleWalletConnectSessionRegister(w, r)
|
||||||
|
case "":
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"endpoints": []string{
|
||||||
|
"/api/v1/walletconnect/config",
|
||||||
|
"/api/v1/walletconnect/metadata",
|
||||||
|
"/api/v1/walletconnect/connect",
|
||||||
|
"POST /api/v1/walletconnect/session",
|
||||||
|
"/api/v1/walletconnect/session/{sessionId}",
|
||||||
|
},
|
||||||
|
"fallbackAuth": "/api/v1/auth/wallet",
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
if strings.HasPrefix(path, "session/") {
|
||||||
|
s.handleWalletConnectSession(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "walletconnect route not found")
|
||||||
|
}
|
||||||
|
}
|
||||||
113
backend/api/rest/walletconnect_internal_test.go
Normal file
113
backend/api/rest/walletconnect_internal_test.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package rest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandleWalletConnectConfig(t *testing.T) {
|
||||||
|
t.Setenv("WALLETCONNECT_PROJECT_ID", "test-project-id")
|
||||||
|
server := NewServer(nil, 138)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/walletconnect/config", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
server.handleWalletConnectConfig(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload map[string]interface{}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if payload["projectId"] != "test-project-id" {
|
||||||
|
t.Fatalf("expected project id, got %#v", payload["projectId"])
|
||||||
|
}
|
||||||
|
if payload["fallbackAuth"] != "/api/v1/auth/wallet" {
|
||||||
|
t.Fatalf("expected fallback auth path, got %#v", payload["fallbackAuth"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleWalletConnectConnectDisabled(t *testing.T) {
|
||||||
|
t.Setenv("WALLETCONNECT_PROJECT_ID", "")
|
||||||
|
server := NewServer(nil, 138)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/walletconnect/connect", strings.NewReader("{}"))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
server.handleWalletConnectConnect(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNotImplemented {
|
||||||
|
t.Fatalf("expected 501, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleWalletConnectConnectClientMode(t *testing.T) {
|
||||||
|
t.Setenv("WALLETCONNECT_PROJECT_ID", "test-project-id")
|
||||||
|
server := NewServer(nil, 138)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/walletconnect/connect", strings.NewReader("{}"))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
server.handleWalletConnectConnect(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload map[string]interface{}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if payload["status"] != "client" {
|
||||||
|
t.Fatalf("expected client status, got %#v", payload["status"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleWalletConnectSessionMissing(t *testing.T) {
|
||||||
|
server := NewServer(nil, 138)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/walletconnect/session/demo-session", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
server.handleWalletConnectSession(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 404, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleWalletConnectSessionRegister(t *testing.T) {
|
||||||
|
server := NewServer(nil, 138)
|
||||||
|
|
||||||
|
body := strings.NewReader(`{"sessionId":"wc-demo","address":"0x4A666F96fC8764181194447A7dFdb7d471b301C8","chainId":138}`)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/walletconnect/session", body)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
server.handleWalletConnectSessionRegister(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
getReq := httptest.NewRequest(http.MethodGet, "/api/v1/walletconnect/session/wc-demo", nil)
|
||||||
|
getRec := httptest.NewRecorder()
|
||||||
|
server.handleWalletConnectSession(getRec, getReq)
|
||||||
|
if getRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected lookup 200, got %d", getRec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleWalletConnectRootIndex(t *testing.T) {
|
||||||
|
_ = os.Setenv("WALLETCONNECT_PROJECT_ID", "")
|
||||||
|
server := NewServer(nil, 138)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/walletconnect", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
server.handleWalletConnectRoot(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
255
backend/api/track1/bridge_lanes.go
Normal file
255
backend/api/track1/bridge_lanes.go
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
package track1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed bridge_lanes_default.json
|
||||||
|
var defaultBridgeLanesJSON []byte
|
||||||
|
|
||||||
|
type bridgeLaneDefinition struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
ChainName string `json:"chain_name"`
|
||||||
|
ChainID int64 `json:"chain_id"`
|
||||||
|
ConfigReady bool `json:"config_ready"`
|
||||||
|
RPCEnvs []string `json:"rpc_envs"`
|
||||||
|
RPCDefault string `json:"rpc_default"`
|
||||||
|
LinkToken string `json:"link_token"`
|
||||||
|
WETH9Bridge string `json:"weth9_bridge"`
|
||||||
|
WETH10Bridge string `json:"weth10_bridge"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type bridgeLanesConfig struct {
|
||||||
|
Updated string `json:"updated"`
|
||||||
|
MinLinkWei string `json:"min_link_wei"`
|
||||||
|
Lanes []bridgeLaneDefinition `json:"lanes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadBridgeLanesConfig() bridgeLanesConfig {
|
||||||
|
path := strings.TrimSpace(os.Getenv("MISSION_CONTROL_BRIDGE_LANES_JSON"))
|
||||||
|
if path != "" {
|
||||||
|
if b, err := os.ReadFile(path); err == nil && len(b) > 0 {
|
||||||
|
var cfg bridgeLanesConfig
|
||||||
|
if json.Unmarshal(b, &cfg) == nil && len(cfg.Lanes) > 0 {
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var cfg bridgeLanesConfig
|
||||||
|
_ = json.Unmarshal(defaultBridgeLanesJSON, &cfg)
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveLaneRPC(def bridgeLaneDefinition) string {
|
||||||
|
for _, key := range def.RPCEnvs {
|
||||||
|
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, row := range ParseExtraRPCProbes() {
|
||||||
|
chainKey := row[2]
|
||||||
|
if chainKey == strconv.FormatInt(def.ChainID, 10) {
|
||||||
|
return row[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(def.RPCDefault)
|
||||||
|
}
|
||||||
|
|
||||||
|
func erc20BalanceOf(ctx context.Context, rpcURL, tokenAddress, holderAddress string) (string, error) {
|
||||||
|
tokenAddress = strings.ToLower(strings.TrimSpace(tokenAddress))
|
||||||
|
holderAddress = strings.ToLower(strings.TrimPrefix(strings.TrimSpace(holderAddress), "0x"))
|
||||||
|
if len(holderAddress) != 40 {
|
||||||
|
return "0", nil
|
||||||
|
}
|
||||||
|
data := "0x70a08231" + strings.Repeat("0", 24) + holderAddress
|
||||||
|
raw, _, err := postJSONRPC(ctx, bridgeLaneHTTPClient(), rpcURL, "eth_call", []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"to": tokenAddress,
|
||||||
|
"data": data,
|
||||||
|
},
|
||||||
|
"latest",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var hex string
|
||||||
|
if err := json.Unmarshal(raw, &hex); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
hex = strings.TrimSpace(hex)
|
||||||
|
if hex == "" || hex == "0x" {
|
||||||
|
return "0", nil
|
||||||
|
}
|
||||||
|
value := new(big.Int)
|
||||||
|
if _, ok := value.SetString(strings.TrimPrefix(hex, "0x"), 16); !ok {
|
||||||
|
return "0", nil
|
||||||
|
}
|
||||||
|
return value.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func bridgeLaneHTTPClient() *http.Client {
|
||||||
|
return &http.Client{Timeout: 6 * time.Second}
|
||||||
|
}
|
||||||
|
|
||||||
|
func bridgeFundingStatus(linkBalanceWei, minLinkWei string) string {
|
||||||
|
balance, okBalance := new(big.Int).SetString(strings.TrimSpace(linkBalanceWei), 10)
|
||||||
|
minimum, okMin := new(big.Int).SetString(strings.TrimSpace(minLinkWei), 10)
|
||||||
|
if !okBalance || !okMin {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
if balance.Cmp(minimum) >= 0 {
|
||||||
|
return "funded"
|
||||||
|
}
|
||||||
|
if balance.Sign() > 0 {
|
||||||
|
return "degraded"
|
||||||
|
}
|
||||||
|
return "unfunded"
|
||||||
|
}
|
||||||
|
|
||||||
|
func proofStatusForLane(key string, proofs map[string]interface{}) string {
|
||||||
|
if proofs == nil {
|
||||||
|
return "proof-pending"
|
||||||
|
}
|
||||||
|
laneProofs, ok := proofs[key].([]interface{})
|
||||||
|
if !ok || len(laneProofs) == 0 {
|
||||||
|
return "proof-pending"
|
||||||
|
}
|
||||||
|
for _, item := range laneProofs {
|
||||||
|
row, ok := item.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if tx, ok := row["tx_hash"].(string); ok && strings.TrimSpace(tx) != "" {
|
||||||
|
return "proof-recorded"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "proof-pending"
|
||||||
|
}
|
||||||
|
|
||||||
|
func readProofTransfersJSON() map[string]interface{} {
|
||||||
|
path := strings.TrimSpace(os.Getenv("MISSION_CONTROL_PROOF_TRANSFERS_JSON"))
|
||||||
|
if path == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil || len(b) == 0 {
|
||||||
|
return map[string]interface{}{"error": "unreadable or empty", "path": path}
|
||||||
|
}
|
||||||
|
if len(b) > 512*1024 {
|
||||||
|
return map[string]interface{}{"error": "file too large", "path": path}
|
||||||
|
}
|
||||||
|
var payload map[string]interface{}
|
||||||
|
if err := json.Unmarshal(b, &payload); err != nil {
|
||||||
|
return map[string]interface{}{"error": err.Error(), "path": path}
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
func probeBridgeContract(ctx context.Context, rpcURL, linkToken, bridgeAddress, minLinkWei string) map[string]interface{} {
|
||||||
|
result := map[string]interface{}{
|
||||||
|
"bridge": strings.TrimSpace(bridgeAddress),
|
||||||
|
}
|
||||||
|
if rpcURL == "" {
|
||||||
|
result["status"] = "unknown"
|
||||||
|
result["error"] = "rpc unavailable"
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
if linkToken == "" || bridgeAddress == "" {
|
||||||
|
result["status"] = "unknown"
|
||||||
|
result["error"] = "missing link token or bridge address"
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
balance, err := erc20BalanceOf(ctx, rpcURL, linkToken, bridgeAddress)
|
||||||
|
if err != nil {
|
||||||
|
result["status"] = "unknown"
|
||||||
|
result["error"] = err.Error()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
result["link_balance_wei"] = balance
|
||||||
|
result["status"] = bridgeFundingStatus(balance, minLinkWei)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func aggregateLaneStatus(weth9Status, weth10Status, proofStatus string) string {
|
||||||
|
statuses := []string{weth9Status, weth10Status}
|
||||||
|
hasUnfunded := false
|
||||||
|
hasDegraded := false
|
||||||
|
hasUnknown := false
|
||||||
|
for _, status := range statuses {
|
||||||
|
switch status {
|
||||||
|
case "unfunded":
|
||||||
|
hasUnfunded = true
|
||||||
|
case "degraded":
|
||||||
|
hasDegraded = true
|
||||||
|
case "unknown":
|
||||||
|
hasUnknown = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasUnfunded {
|
||||||
|
return "unfunded"
|
||||||
|
}
|
||||||
|
if hasDegraded {
|
||||||
|
return "degraded"
|
||||||
|
}
|
||||||
|
if hasUnknown {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
if proofStatus == "proof-pending" {
|
||||||
|
return "proof-pending"
|
||||||
|
}
|
||||||
|
return "funded"
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildBridgeLaneHealth(ctx context.Context) (map[string]interface{}, map[string]interface{}) {
|
||||||
|
cfg := loadBridgeLanesConfig()
|
||||||
|
minLinkWei := strings.TrimSpace(cfg.MinLinkWei)
|
||||||
|
if minLinkWei == "" {
|
||||||
|
minLinkWei = "1000000000000000000"
|
||||||
|
}
|
||||||
|
|
||||||
|
proofPayload := readProofTransfersJSON()
|
||||||
|
proofByLane := map[string]interface{}{}
|
||||||
|
if proofPayload != nil {
|
||||||
|
if lanes, ok := proofPayload["lanes"].(map[string]interface{}); ok {
|
||||||
|
proofByLane = lanes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lanes := make([]map[string]interface{}, 0, len(cfg.Lanes))
|
||||||
|
for _, def := range cfg.Lanes {
|
||||||
|
rpcURL := resolveLaneRPC(def)
|
||||||
|
weth9 := probeBridgeContract(ctx, rpcURL, def.LinkToken, def.WETH9Bridge, minLinkWei)
|
||||||
|
weth10 := probeBridgeContract(ctx, rpcURL, def.LinkToken, def.WETH10Bridge, minLinkWei)
|
||||||
|
weth9Status, _ := weth9["status"].(string)
|
||||||
|
weth10Status, _ := weth10["status"].(string)
|
||||||
|
proofStatus := proofStatusForLane(def.Key, proofByLane)
|
||||||
|
|
||||||
|
lanes = append(lanes, map[string]interface{}{
|
||||||
|
"key": def.Key,
|
||||||
|
"chain_name": def.ChainName,
|
||||||
|
"chain_id": def.ChainID,
|
||||||
|
"config_ready": def.ConfigReady,
|
||||||
|
"link_token": def.LinkToken,
|
||||||
|
"status": aggregateLaneStatus(weth9Status, weth10Status, proofStatus),
|
||||||
|
"proof_status": proofStatus,
|
||||||
|
"weth9": weth9,
|
||||||
|
"weth10": weth10,
|
||||||
|
"rpc_endpoint": redactRPCOrigin(rpcURL),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
laneHealth := map[string]interface{}{
|
||||||
|
"updated_at": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
"min_link_wei": minLinkWei,
|
||||||
|
"lanes": lanes,
|
||||||
|
}
|
||||||
|
return laneHealth, proofPayload
|
||||||
|
}
|
||||||
61
backend/api/track1/bridge_lanes_default.json
Normal file
61
backend/api/track1/bridge_lanes_default.json
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"updated": "2026-05-23",
|
||||||
|
"min_link_wei": "1000000000000000000",
|
||||||
|
"lanes": [
|
||||||
|
{
|
||||||
|
"key": "chain138",
|
||||||
|
"chain_name": "Defi Oracle Meta Mainnet (138)",
|
||||||
|
"chain_id": 138,
|
||||||
|
"config_ready": true,
|
||||||
|
"rpc_envs": ["RPC_URL", "RPC_URL_138"],
|
||||||
|
"rpc_default": "http://192.168.11.211:8545",
|
||||||
|
"link_token": "0xb7721dd53a8c629d9f1ba31a5819afe250002b03",
|
||||||
|
"weth9_bridge": "0xcacfd227A040002e49e2e01626363071324f820a",
|
||||||
|
"weth10_bridge": "0xe0E93247376aa097dB308B92e6Ba36bA015535D0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "gnosis",
|
||||||
|
"chain_name": "Gnosis (100)",
|
||||||
|
"chain_id": 100,
|
||||||
|
"config_ready": true,
|
||||||
|
"rpc_envs": ["GNOSIS_RPC", "GNOSIS_MAINNET_RPC", "GNOSIS_RPC_URL"],
|
||||||
|
"rpc_default": "https://rpc.gnosischain.com",
|
||||||
|
"link_token": "0xE2e73A1c69ecF83F464EFCE6A5be353a37cA09b2",
|
||||||
|
"weth9_bridge": "0xc8656F24488cb90c452058da92d1a25BA464eaAE",
|
||||||
|
"weth10_bridge": "0xa846aeAD3071df1b6439d5D813156aCE7C2c1DA1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "cronos",
|
||||||
|
"chain_name": "Cronos (25)",
|
||||||
|
"chain_id": 25,
|
||||||
|
"config_ready": true,
|
||||||
|
"rpc_envs": ["CRONOS_RPC", "CRONOS_RPC_URL", "CRONOS_MAINNET_RPC"],
|
||||||
|
"rpc_default": "https://evm.cronos.org",
|
||||||
|
"link_token": "0x8c80A01F461f297Df7F9DA3A4f740D7297C8Ac85",
|
||||||
|
"weth9_bridge": "0x3Cc23d086fCcbAe1e5f3FE2bA4A263E1D27d8Cab",
|
||||||
|
"weth10_bridge": "0x105F8A15b819948a89153505762444Ee9f324684"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "celo",
|
||||||
|
"chain_name": "Celo (42220)",
|
||||||
|
"chain_id": 42220,
|
||||||
|
"config_ready": true,
|
||||||
|
"rpc_envs": ["CELO_RPC", "CELO_MAINNET_RPC"],
|
||||||
|
"rpc_default": "https://forno.celo.org",
|
||||||
|
"link_token": "0xd07294e6E917e07dfDcee882dd1e2565085C2ae0",
|
||||||
|
"weth9_bridge": "0xAb57BF30F1354CA0590af22D8974c7f24DB2DbD7",
|
||||||
|
"weth10_bridge": "0xa780ef19A041745d353c9432f2a7f5A241335ffE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "wemix",
|
||||||
|
"chain_name": "Wemix (1111)",
|
||||||
|
"chain_id": 1111,
|
||||||
|
"config_ready": true,
|
||||||
|
"rpc_envs": ["WEMIX_RPC", "WEMIX_MAINNET_RPC"],
|
||||||
|
"rpc_default": "https://api.wemix.com",
|
||||||
|
"link_token": "0x80f1FcdC96B55e459BF52b998aBBE2c364935d69",
|
||||||
|
"weth9_bridge": "0xD3AD6831aacB5386B8A25BB8D8176a6C8a026f04",
|
||||||
|
"weth10_bridge": "0xa4B9DD039565AeD9641D45b57061f99d9cA6Df08"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
37
backend/api/track1/bridge_lanes_test.go
Normal file
37
backend/api/track1/bridge_lanes_test.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package track1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBridgeFundingStatus(t *testing.T) {
|
||||||
|
require.Equal(t, "funded", bridgeFundingStatus("2000000000000000000", "1000000000000000000"))
|
||||||
|
require.Equal(t, "degraded", bridgeFundingStatus("500000000000000000", "1000000000000000000"))
|
||||||
|
require.Equal(t, "unfunded", bridgeFundingStatus("0", "1000000000000000000"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAggregateLaneStatus(t *testing.T) {
|
||||||
|
require.Equal(t, "unfunded", aggregateLaneStatus("unfunded", "funded", "proof-recorded"))
|
||||||
|
require.Equal(t, "degraded", aggregateLaneStatus("degraded", "funded", "proof-recorded"))
|
||||||
|
require.Equal(t, "proof-pending", aggregateLaneStatus("funded", "funded", "proof-pending"))
|
||||||
|
require.Equal(t, "funded", aggregateLaneStatus("funded", "funded", "proof-recorded"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProofStatusForLane(t *testing.T) {
|
||||||
|
proofs := map[string]interface{}{
|
||||||
|
"gnosis": []interface{}{
|
||||||
|
map[string]interface{}{"tx_hash": "0xabc"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
require.Equal(t, "proof-recorded", proofStatusForLane("gnosis", proofs))
|
||||||
|
require.Equal(t, "proof-pending", proofStatusForLane("cronos", proofs))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadBridgeLanesConfigDefault(t *testing.T) {
|
||||||
|
t.Setenv("MISSION_CONTROL_BRIDGE_LANES_JSON", "")
|
||||||
|
cfg := loadBridgeLanesConfig()
|
||||||
|
require.NotEmpty(t, cfg.Lanes)
|
||||||
|
require.NotEmpty(t, cfg.MinLinkWei)
|
||||||
|
}
|
||||||
78
backend/api/track1/bridge_mode.go
Normal file
78
backend/api/track1/bridge_mode.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package track1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/explorer/backend/api/freshness"
|
||||||
|
)
|
||||||
|
|
||||||
|
type bridgeDeliveryMode struct {
|
||||||
|
Kind string
|
||||||
|
Reason any
|
||||||
|
Scope any
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveBridgeDeliveryMode(hasRelays bool, diagnostics *freshness.Diagnostics, txFeed freshness.Completeness) bridgeDeliveryMode {
|
||||||
|
if !hasRelays {
|
||||||
|
if diagnostics != nil && isStaleTransactionVisibility(diagnostics) {
|
||||||
|
return bridgeDeliveryMode{
|
||||||
|
Kind: "mixed",
|
||||||
|
Reason: "partial_observability_inputs",
|
||||||
|
Scope: "homepage_summary_only",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bridgeDeliveryMode{
|
||||||
|
Kind: "live",
|
||||||
|
Reason: nil,
|
||||||
|
Scope: nil,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if diagnostics != nil && isStaleTransactionVisibility(diagnostics) {
|
||||||
|
return bridgeDeliveryMode{
|
||||||
|
Kind: "mixed",
|
||||||
|
Reason: "relay_snapshot_only_source",
|
||||||
|
Scope: "bridge_monitoring_and_homepage",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if txFeed == freshness.CompletenessPartial || txFeed == freshness.CompletenessStale {
|
||||||
|
return bridgeDeliveryMode{
|
||||||
|
Kind: "mixed",
|
||||||
|
Reason: "partial_observability_inputs",
|
||||||
|
Scope: "bridge_monitoring_and_homepage",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bridgeDeliveryMode{
|
||||||
|
Kind: "snapshot",
|
||||||
|
Reason: "live_homepage_stream_not_attached",
|
||||||
|
Scope: "relay_monitoring_homepage_card_only",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isStaleTransactionVisibility(diagnostics *freshness.Diagnostics) bool {
|
||||||
|
if diagnostics == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
state := strings.ToLower(strings.TrimSpace(diagnostics.ActivityState))
|
||||||
|
switch state {
|
||||||
|
case "fresh_head_stale_transaction_visibility", "lagging", "stale_transaction_visibility":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return strings.Contains(state, "stale") && strings.Contains(state, "transaction")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildBridgeModePayload(now string, resolved bridgeDeliveryMode) map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"kind": resolved.Kind,
|
||||||
|
"updated_at": now,
|
||||||
|
"age_seconds": int64(0),
|
||||||
|
"reason": resolved.Reason,
|
||||||
|
"scope": resolved.Scope,
|
||||||
|
"source": freshness.SourceReported,
|
||||||
|
"confidence": freshness.ConfidenceHigh,
|
||||||
|
"provenance": freshness.ProvenanceMissionFeed,
|
||||||
|
}
|
||||||
|
}
|
||||||
44
backend/api/track1/bridge_mode_test.go
Normal file
44
backend/api/track1/bridge_mode_test.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package track1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/explorer/backend/api/freshness"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveBridgeDeliveryModeLiveWithoutRelays(t *testing.T) {
|
||||||
|
got := resolveBridgeDeliveryMode(false, nil, freshness.CompletenessComplete)
|
||||||
|
require.Equal(t, "live", got.Kind)
|
||||||
|
require.Nil(t, got.Reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveBridgeDeliveryModeSnapshotWithRelays(t *testing.T) {
|
||||||
|
got := resolveBridgeDeliveryMode(true, nil, freshness.CompletenessComplete)
|
||||||
|
require.Equal(t, "snapshot", got.Kind)
|
||||||
|
require.Equal(t, "live_homepage_stream_not_attached", got.Reason)
|
||||||
|
require.Equal(t, "relay_monitoring_homepage_card_only", got.Scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveBridgeDeliveryModeMixedWhenTransactionVisibilityStale(t *testing.T) {
|
||||||
|
diagnostics := &freshness.Diagnostics{
|
||||||
|
ActivityState: "fresh_head_stale_transaction_visibility",
|
||||||
|
}
|
||||||
|
got := resolveBridgeDeliveryMode(true, diagnostics, freshness.CompletenessPartial)
|
||||||
|
require.Equal(t, "mixed", got.Kind)
|
||||||
|
require.Equal(t, "relay_snapshot_only_source", got.Reason)
|
||||||
|
require.Equal(t, "bridge_monitoring_and_homepage", got.Scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveBridgeDeliveryModeMixedWhenQuietChain(t *testing.T) {
|
||||||
|
diagnostics := &freshness.Diagnostics{
|
||||||
|
ActivityState: "quiet_chain",
|
||||||
|
}
|
||||||
|
got := resolveBridgeDeliveryMode(false, diagnostics, freshness.CompletenessComplete)
|
||||||
|
require.Equal(t, "live", got.Kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsStaleTransactionVisibility(t *testing.T) {
|
||||||
|
require.True(t, isStaleTransactionVisibility(&freshness.Diagnostics{ActivityState: "fresh_head_stale_transaction_visibility"}))
|
||||||
|
require.False(t, isStaleTransactionVisibility(&freshness.Diagnostics{ActivityState: "healthy"}))
|
||||||
|
}
|
||||||
@@ -133,6 +133,8 @@ func (s *Server) BuildBridgeStatusData(ctx context.Context) map[string]interface
|
|||||||
}
|
}
|
||||||
if s.freshnessLoader != nil {
|
if s.freshnessLoader != nil {
|
||||||
if snapshot, completeness, sampling, diagnostics, err := s.freshnessLoader(ctx); err == nil && snapshot != nil {
|
if snapshot, completeness, sampling, diagnostics, err := s.freshnessLoader(ctx); err == nil && snapshot != nil {
|
||||||
|
txFeed := completeness.TransactionsFeed
|
||||||
|
resolvedMode := resolveBridgeDeliveryMode(false, diagnostics, txFeed)
|
||||||
subsystems := map[string]interface{}{
|
subsystems := map[string]interface{}{
|
||||||
"rpc_head": map[string]interface{}{
|
"rpc_head": map[string]interface{}{
|
||||||
"status": chainStatusFromProbe(p138),
|
"status": chainStatusFromProbe(p138),
|
||||||
@@ -174,39 +176,13 @@ func (s *Server) BuildBridgeStatusData(ctx context.Context) map[string]interface
|
|||||||
"issues": sampling.Issues,
|
"issues": sampling.Issues,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
modeKind := "live"
|
|
||||||
modeReason := any(nil)
|
|
||||||
modeScope := any(nil)
|
|
||||||
if relays, ok := data["ccip_relays"].(map[string]interface{}); ok && len(relays) > 0 {
|
|
||||||
modeKind = "snapshot"
|
|
||||||
modeReason = "live_homepage_stream_not_attached"
|
|
||||||
modeScope = "relay_monitoring_homepage_card_only"
|
|
||||||
subsystems["bridge_relay_monitoring"] = map[string]interface{}{
|
|
||||||
"status": overall,
|
|
||||||
"updated_at": now,
|
|
||||||
"age_seconds": int64(0),
|
|
||||||
"source": freshness.SourceReported,
|
|
||||||
"confidence": freshness.ConfidenceHigh,
|
|
||||||
"provenance": freshness.ProvenanceMissionFeed,
|
|
||||||
"completeness": freshness.CompletenessComplete,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
data["freshness"] = snapshot
|
data["freshness"] = snapshot
|
||||||
data["subsystems"] = subsystems
|
data["subsystems"] = subsystems
|
||||||
data["sampling"] = sampling
|
data["sampling"] = sampling
|
||||||
if diagnostics != nil {
|
if diagnostics != nil {
|
||||||
data["diagnostics"] = diagnostics
|
data["diagnostics"] = diagnostics
|
||||||
}
|
}
|
||||||
data["mode"] = map[string]interface{}{
|
data["mode"] = buildBridgeModePayload(now, resolvedMode)
|
||||||
"kind": modeKind,
|
|
||||||
"updated_at": now,
|
|
||||||
"age_seconds": int64(0),
|
|
||||||
"reason": modeReason,
|
|
||||||
"scope": modeScope,
|
|
||||||
"source": freshness.SourceReported,
|
|
||||||
"confidence": freshness.ConfidenceHigh,
|
|
||||||
"provenance": freshness.ProvenanceMissionFeed,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if relays := FetchCCIPRelayHealths(ctx); relays != nil {
|
if relays := FetchCCIPRelayHealths(ctx); relays != nil {
|
||||||
@@ -222,11 +198,30 @@ func (s *Server) BuildBridgeStatusData(ctx context.Context) map[string]interface
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if laneHealth, proofTransfers := BuildBridgeLaneHealth(ctx); laneHealth != nil {
|
||||||
|
data["bridge_lanes"] = laneHealth
|
||||||
|
if proofTransfers != nil {
|
||||||
|
data["proof_transfers"] = proofTransfers
|
||||||
|
}
|
||||||
|
}
|
||||||
if mode, ok := data["mode"].(map[string]interface{}); ok {
|
if mode, ok := data["mode"].(map[string]interface{}); ok {
|
||||||
if relays, ok := data["ccip_relays"].(map[string]interface{}); ok && len(relays) > 0 {
|
if relays, ok := data["ccip_relays"].(map[string]interface{}); ok && len(relays) > 0 {
|
||||||
mode["kind"] = "snapshot"
|
var diagnostics *freshness.Diagnostics
|
||||||
mode["reason"] = "live_homepage_stream_not_attached"
|
if diag, ok := data["diagnostics"].(*freshness.Diagnostics); ok {
|
||||||
mode["scope"] = "relay_monitoring_homepage_card_only"
|
diagnostics = diag
|
||||||
|
}
|
||||||
|
txFeed := freshness.CompletenessUnavailable
|
||||||
|
if subsystems, ok := data["subsystems"].(map[string]interface{}); ok {
|
||||||
|
if txIndex, ok := subsystems["tx_index"].(map[string]interface{}); ok {
|
||||||
|
if feed, ok := txIndex["completeness"].(freshness.Completeness); ok {
|
||||||
|
txFeed = feed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolved := resolveBridgeDeliveryMode(true, diagnostics, txFeed)
|
||||||
|
mode["kind"] = resolved.Kind
|
||||||
|
mode["reason"] = resolved.Reason
|
||||||
|
mode["scope"] = resolved.Scope
|
||||||
if subsystems, ok := data["subsystems"].(map[string]interface{}); ok {
|
if subsystems, ok := data["subsystems"].(map[string]interface{}); ok {
|
||||||
subsystems["bridge_relay_monitoring"] = map[string]interface{}{
|
subsystems["bridge_relay_monitoring"] = map[string]interface{}{
|
||||||
"status": data["status"],
|
"status": data["status"],
|
||||||
@@ -239,6 +234,9 @@ func (s *Server) BuildBridgeStatusData(ctx context.Context) map[string]interface
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if relays, ok := data["ccip_relays"].(map[string]interface{}); ok && len(relays) > 0 {
|
||||||
|
resolved := resolveBridgeDeliveryMode(true, nil, freshness.CompletenessUnavailable)
|
||||||
|
data["mode"] = buildBridgeModePayload(now, resolved)
|
||||||
}
|
}
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -212,6 +212,11 @@ func TestBuildBridgeStatusDataIncludesCCIPRelay(t *testing.T) {
|
|||||||
require.Contains(t, got, "diagnostics")
|
require.Contains(t, got, "diagnostics")
|
||||||
require.Contains(t, got, "subsystems")
|
require.Contains(t, got, "subsystems")
|
||||||
require.Contains(t, got, "mode")
|
require.Contains(t, got, "mode")
|
||||||
|
mode, ok := got["mode"].(map[string]interface{})
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, "mixed", mode["kind"])
|
||||||
|
require.Equal(t, "relay_snapshot_only_source", mode["reason"])
|
||||||
|
require.Equal(t, "bridge_monitoring_and_homepage", mode["scope"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildBridgeStatusDataDegradesWhenNamedRelayFails(t *testing.T) {
|
func TestBuildBridgeStatusDataDegradesWhenNamedRelayFails(t *testing.T) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/explorer/backend/api/middleware"
|
||||||
"github.com/explorer/backend/auth"
|
"github.com/explorer/backend/auth"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
)
|
)
|
||||||
@@ -185,7 +186,7 @@ func (s *Server) requireOperatorAccess(w http.ResponseWriter, r *http.Request) (
|
|||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
operatorAddr, _ := r.Context().Value("user_address").(string)
|
operatorAddr := middleware.UserAddress(r.Context())
|
||||||
operatorAddr = strings.TrimSpace(operatorAddr)
|
operatorAddr = strings.TrimSpace(operatorAddr)
|
||||||
if operatorAddr == "" {
|
if operatorAddr == "" {
|
||||||
writeError(w, http.StatusUnauthorized, "unauthorized", "Operator address required")
|
writeError(w, http.StatusUnauthorized, "unauthorized", "Operator address required")
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/explorer/backend/api/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
type runScriptRequest struct {
|
type runScriptRequest struct {
|
||||||
@@ -67,7 +69,7 @@ func (s *Server) HandleRunScript(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
operatorAddr, _ := r.Context().Value("user_address").(string)
|
operatorAddr := middleware.UserAddress(r.Context())
|
||||||
if operatorAddr == "" {
|
if operatorAddr == "" {
|
||||||
writeError(w, http.StatusUnauthorized, "unauthorized", "Operator address required")
|
writeError(w, http.StatusUnauthorized, "unauthorized", "Operator address required")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
|
||||||
|
"github.com/explorer/backend/api/middleware"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -45,7 +46,7 @@ func TestHandleRunScriptUsesForwardedClientIPAndRunsAllowlistedScript(t *testing
|
|||||||
|
|
||||||
reqBody := []byte(`{"script":"echo.sh","args":["world"]}`)
|
reqBody := []byte(`{"script":"echo.sh","args":["world"]}`)
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/track4/operator/run-script", bytes.NewReader(reqBody))
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/track4/operator/run-script", bytes.NewReader(reqBody))
|
||||||
req = req.WithContext(context.WithValue(req.Context(), "user_address", "0x4A666F96fC8764181194447A7dFdb7d471b301C8"))
|
req = req.WithContext(middleware.ContextWithAuth(req.Context(), "0x4A666F96fC8764181194447A7dFdb7d471b301C8", 4, true))
|
||||||
req.RemoteAddr = "10.0.0.10:8080"
|
req.RemoteAddr = "10.0.0.10:8080"
|
||||||
req.Header.Set("X-Forwarded-For", "203.0.113.9, 10.0.0.10")
|
req.Header.Set("X-Forwarded-For", "203.0.113.9, 10.0.0.10")
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
@@ -77,7 +78,7 @@ func TestHandleRunScriptRejectsNonAllowlistedScript(t *testing.T) {
|
|||||||
s := &Server{roleMgr: &stubRoleManager{allowed: true}, chainID: 138}
|
s := &Server{roleMgr: &stubRoleManager{allowed: true}, chainID: 138}
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/track4/operator/run-script", bytes.NewReader([]byte(`{"script":"blocked.sh"}`)))
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/track4/operator/run-script", bytes.NewReader([]byte(`{"script":"blocked.sh"}`)))
|
||||||
req = req.WithContext(context.WithValue(req.Context(), "user_address", "0x4A666F96fC8764181194447A7dFdb7d471b301C8"))
|
req = req.WithContext(middleware.ContextWithAuth(req.Context(), "0x4A666F96fC8764181194447A7dFdb7d471b301C8", 4, true))
|
||||||
req.RemoteAddr = "127.0.0.1:9999"
|
req.RemoteAddr = "127.0.0.1:9999"
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
@@ -100,7 +101,7 @@ func TestHandleRunScriptRejectsFilenameCollisionOutsideAllowlistedPath(t *testin
|
|||||||
s := &Server{roleMgr: &stubRoleManager{allowed: true}, chainID: 138}
|
s := &Server{roleMgr: &stubRoleManager{allowed: true}, chainID: 138}
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/track4/operator/run-script", bytes.NewReader([]byte(`{"script":"unsafe/backup.sh"}`)))
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/track4/operator/run-script", bytes.NewReader([]byte(`{"script":"unsafe/backup.sh"}`)))
|
||||||
req = req.WithContext(context.WithValue(req.Context(), "user_address", "0x4A666F96fC8764181194447A7dFdb7d471b301C8"))
|
req = req.WithContext(middleware.ContextWithAuth(req.Context(), "0x4A666F96fC8764181194447A7dFdb7d471b301C8", 4, true))
|
||||||
req.RemoteAddr = "127.0.0.1:9999"
|
req.RemoteAddr = "127.0.0.1:9999"
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
@@ -122,7 +123,7 @@ func TestHandleRunScriptTruncatesLargeOutput(t *testing.T) {
|
|||||||
s := &Server{roleMgr: &stubRoleManager{allowed: true}, chainID: 138}
|
s := &Server{roleMgr: &stubRoleManager{allowed: true}, chainID: 138}
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/track4/operator/run-script", bytes.NewReader([]byte(`{"script":"large.sh"}`)))
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/track4/operator/run-script", bytes.NewReader([]byte(`{"script":"large.sh"}`)))
|
||||||
req = req.WithContext(context.WithValue(req.Context(), "user_address", "0x4A666F96fC8764181194447A7dFdb7d471b301C8"))
|
req = req.WithContext(middleware.ContextWithAuth(req.Context(), "0x4A666F96fC8764181194447A7dFdb7d471b301C8", 4, true))
|
||||||
req.RemoteAddr = "127.0.0.1:9999"
|
req.RemoteAddr = "127.0.0.1:9999"
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
|||||||
213
backend/auth/membership.go
Normal file
213
backend/auth/membership.go
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InstitutionalTier represents a DBIS institutional membership tier.
|
||||||
|
// These are the canonical tiers from d-bis.org/members#tiers.
|
||||||
|
type InstitutionalTier string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TierSovereignCentralBank InstitutionalTier = "sovereign_central_bank"
|
||||||
|
TierGlobalFamilyOffice InstitutionalTier = "global_family_office"
|
||||||
|
TierSettlementMember InstitutionalTier = "settlement_member"
|
||||||
|
TierInfrastructureOp InstitutionalTier = "infrastructure_operator"
|
||||||
|
TierOversightJudicial InstitutionalTier = "oversight_judicial"
|
||||||
|
TierDelegatedAuthority InstitutionalTier = "delegated_authority"
|
||||||
|
TierStandardsBody InstitutionalTier = "standards_body"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InstitutionalTierLabel returns the human-readable label for a tier.
|
||||||
|
func InstitutionalTierLabel(t InstitutionalTier) string {
|
||||||
|
switch t {
|
||||||
|
case TierSovereignCentralBank:
|
||||||
|
return "Sovereign Central Bank"
|
||||||
|
case TierGlobalFamilyOffice:
|
||||||
|
return "Global Family Office"
|
||||||
|
case TierSettlementMember:
|
||||||
|
return "Settlement Member"
|
||||||
|
case TierInfrastructureOp:
|
||||||
|
return "Infrastructure Operator"
|
||||||
|
case TierOversightJudicial:
|
||||||
|
return "Oversight & Judicial"
|
||||||
|
case TierDelegatedAuthority:
|
||||||
|
return "Delegated Authority"
|
||||||
|
case TierStandardsBody:
|
||||||
|
return "Standards Body"
|
||||||
|
default:
|
||||||
|
return string(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultTrackForTier maps an institutional membership tier to the default
|
||||||
|
// explorer access track. Higher tracks inherit all lower-track permissions.
|
||||||
|
//
|
||||||
|
// Track 1 — Public explorer (read blocks, txs, basic address)
|
||||||
|
// Track 2 — Enhanced explorer (full address, tokens, tx history, search)
|
||||||
|
// Track 3 — Analytics (flows, bridge analytics, risk, distribution)
|
||||||
|
// Track 4 — Operator (bridge control, validators, protocol config)
|
||||||
|
func DefaultTrackForTier(tier InstitutionalTier) int {
|
||||||
|
switch tier {
|
||||||
|
case TierSovereignCentralBank:
|
||||||
|
return 3 // analytics access; operator granted per-address
|
||||||
|
case TierGlobalFamilyOffice:
|
||||||
|
return 3
|
||||||
|
case TierSettlementMember:
|
||||||
|
return 2
|
||||||
|
case TierInfrastructureOp:
|
||||||
|
return 4
|
||||||
|
case TierOversightJudicial:
|
||||||
|
return 3
|
||||||
|
case TierDelegatedAuthority:
|
||||||
|
return 3
|
||||||
|
case TierStandardsBody:
|
||||||
|
return 2
|
||||||
|
default:
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstitutionalMember represents an entity in the DBIS member directory.
|
||||||
|
type InstitutionalMember struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Abbreviation string `json:"abbreviation"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Tier InstitutionalTier `json:"tier"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Jurisdiction string `json:"jurisdiction,omitempty"`
|
||||||
|
LEI string `json:"lei,omitempty"`
|
||||||
|
Latitude float64 `json:"latitude,omitempty"`
|
||||||
|
Longitude float64 `json:"longitude,omitempty"`
|
||||||
|
MapLabel string `json:"map_label,omitempty"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MembershipStore provides read/write access to the institutional members table.
|
||||||
|
type MembershipStore struct {
|
||||||
|
db *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMembershipStore creates a new MembershipStore.
|
||||||
|
func NewMembershipStore(db *pgxpool.Pool) *MembershipStore {
|
||||||
|
return &MembershipStore{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isMissingMembershipTableError(err error) bool {
|
||||||
|
return err != nil && strings.Contains(err.Error(), `relation "institutional_members" does not exist`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMembers returns all active institutional members.
|
||||||
|
func (s *MembershipStore) ListMembers(ctx context.Context) ([]InstitutionalMember, error) {
|
||||||
|
rows, err := s.db.Query(ctx, `
|
||||||
|
SELECT id, slug, abbreviation, name, tier, description,
|
||||||
|
COALESCE(jurisdiction, ''), COALESCE(lei, ''),
|
||||||
|
COALESCE(latitude, 0), COALESCE(longitude, 0),
|
||||||
|
COALESCE(map_label, ''), active
|
||||||
|
FROM institutional_members
|
||||||
|
WHERE active = TRUE
|
||||||
|
ORDER BY tier, name
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
if isMissingMembershipTableError(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("list members: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var members []InstitutionalMember
|
||||||
|
for rows.Next() {
|
||||||
|
var m InstitutionalMember
|
||||||
|
if err := rows.Scan(
|
||||||
|
&m.ID, &m.Slug, &m.Abbreviation, &m.Name, &m.Tier, &m.Description,
|
||||||
|
&m.Jurisdiction, &m.LEI, &m.Latitude, &m.Longitude, &m.MapLabel, &m.Active,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan member: %w", err)
|
||||||
|
}
|
||||||
|
members = append(members, m)
|
||||||
|
}
|
||||||
|
return members, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMemberBySlug returns a single member by URL slug.
|
||||||
|
func (s *MembershipStore) GetMemberBySlug(ctx context.Context, slug string) (*InstitutionalMember, error) {
|
||||||
|
var m InstitutionalMember
|
||||||
|
err := s.db.QueryRow(ctx, `
|
||||||
|
SELECT id, slug, abbreviation, name, tier, description,
|
||||||
|
COALESCE(jurisdiction, ''), COALESCE(lei, ''),
|
||||||
|
COALESCE(latitude, 0), COALESCE(longitude, 0),
|
||||||
|
COALESCE(map_label, ''), active
|
||||||
|
FROM institutional_members
|
||||||
|
WHERE slug = $1
|
||||||
|
`, slug).Scan(
|
||||||
|
&m.ID, &m.Slug, &m.Abbreviation, &m.Name, &m.Tier, &m.Description,
|
||||||
|
&m.Jurisdiction, &m.LEI, &m.Latitude, &m.Longitude, &m.MapLabel, &m.Active,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if isMissingMembershipTableError(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("get member by slug: %w", err)
|
||||||
|
}
|
||||||
|
return &m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMemberByAddress looks up the institutional member linked to a wallet
|
||||||
|
// address via the institutional_member_wallets junction table.
|
||||||
|
func (s *MembershipStore) GetMemberByAddress(ctx context.Context, address string) (*InstitutionalMember, error) {
|
||||||
|
var m InstitutionalMember
|
||||||
|
err := s.db.QueryRow(ctx, `
|
||||||
|
SELECT m.id, m.slug, m.abbreviation, m.name, m.tier, m.description,
|
||||||
|
COALESCE(m.jurisdiction, ''), COALESCE(m.lei, ''),
|
||||||
|
COALESCE(m.latitude, 0), COALESCE(m.longitude, 0),
|
||||||
|
COALESCE(m.map_label, ''), m.active
|
||||||
|
FROM institutional_members m
|
||||||
|
JOIN institutional_member_wallets w ON w.member_id = m.id
|
||||||
|
WHERE LOWER(w.address) = LOWER($1) AND w.active = TRUE AND m.active = TRUE
|
||||||
|
`, address).Scan(
|
||||||
|
&m.ID, &m.Slug, &m.Abbreviation, &m.Name, &m.Tier, &m.Description,
|
||||||
|
&m.Jurisdiction, &m.LEI, &m.Latitude, &m.Longitude, &m.MapLabel, &m.Active,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if isMissingMembershipTableError(err) || strings.Contains(err.Error(), "no rows") {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("get member by address: %w", err)
|
||||||
|
}
|
||||||
|
return &m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTiers returns the canonical set of institutional membership tiers
|
||||||
|
// with their labels and default access tracks.
|
||||||
|
func ListTiers() []struct {
|
||||||
|
Tier InstitutionalTier `json:"tier"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
DefaultTrack int `json:"default_track"`
|
||||||
|
} {
|
||||||
|
tiers := []InstitutionalTier{
|
||||||
|
TierSovereignCentralBank,
|
||||||
|
TierGlobalFamilyOffice,
|
||||||
|
TierSettlementMember,
|
||||||
|
TierInfrastructureOp,
|
||||||
|
TierOversightJudicial,
|
||||||
|
TierDelegatedAuthority,
|
||||||
|
TierStandardsBody,
|
||||||
|
}
|
||||||
|
result := make([]struct {
|
||||||
|
Tier InstitutionalTier `json:"tier"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
DefaultTrack int `json:"default_track"`
|
||||||
|
}, len(tiers))
|
||||||
|
for i, t := range tiers {
|
||||||
|
result[i].Tier = t
|
||||||
|
result[i].Label = InstitutionalTierLabel(t)
|
||||||
|
result[i].DefaultTrack = DefaultTrackForTier(t)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -21,8 +21,49 @@ var (
|
|||||||
ErrWalletNonceNotFoundOrExpired = errors.New("nonce not found or expired")
|
ErrWalletNonceNotFoundOrExpired = errors.New("nonce not found or expired")
|
||||||
ErrWalletNonceExpired = errors.New("nonce expired")
|
ErrWalletNonceExpired = errors.New("nonce expired")
|
||||||
ErrWalletNonceInvalid = errors.New("invalid nonce")
|
ErrWalletNonceInvalid = errors.New("invalid nonce")
|
||||||
|
ErrJWTRevoked = errors.New("token has been revoked")
|
||||||
|
ErrJWTRevocationStorageMissing = errors.New("jwt_revocations table missing; run migration 0016_jwt_revocations")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// tokenTTLs maps each track to its maximum JWT lifetime. Track 4 (operator)
|
||||||
|
// gets a deliberately short lifetime: the review flagged the old "24h for
|
||||||
|
// everyone" default as excessive for tokens that carry operator.write.*
|
||||||
|
// permissions. Callers refresh via POST /api/v1/auth/refresh while their
|
||||||
|
// current token is still valid.
|
||||||
|
var tokenTTLs = map[int]time.Duration{
|
||||||
|
1: 12 * time.Hour,
|
||||||
|
2: 8 * time.Hour,
|
||||||
|
3: 4 * time.Hour,
|
||||||
|
4: 60 * time.Minute,
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultTokenTTL is used for any track not explicitly listed above.
|
||||||
|
const defaultTokenTTL = 12 * time.Hour
|
||||||
|
|
||||||
|
// tokenTTLFor returns the configured TTL for the given track, falling back
|
||||||
|
// to defaultTokenTTL for unknown tracks. Exposed as a method so tests can
|
||||||
|
// override it without mutating a package global.
|
||||||
|
func tokenTTLFor(track int) time.Duration {
|
||||||
|
if ttl, ok := tokenTTLs[track]; ok {
|
||||||
|
return ttl
|
||||||
|
}
|
||||||
|
return defaultTokenTTL
|
||||||
|
}
|
||||||
|
|
||||||
|
func isMissingJWTRevocationTableError(err error) bool {
|
||||||
|
return err != nil && strings.Contains(err.Error(), `relation "jwt_revocations" does not exist`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newJTI returns a random JWT ID used for revocation tracking. 16 random
|
||||||
|
// bytes = 128 bits of entropy, hex-encoded.
|
||||||
|
func newJTI() (string, error) {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", fmt.Errorf("generate jti: %w", err)
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
// WalletAuth handles wallet-based authentication
|
// WalletAuth handles wallet-based authentication
|
||||||
type WalletAuth struct {
|
type WalletAuth struct {
|
||||||
db *pgxpool.Pool
|
db *pgxpool.Pool
|
||||||
@@ -61,10 +102,18 @@ type WalletAuthRequest struct {
|
|||||||
|
|
||||||
// WalletAuthResponse represents a wallet authentication response
|
// WalletAuthResponse represents a wallet authentication response
|
||||||
type WalletAuthResponse struct {
|
type WalletAuthResponse struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
ExpiresAt time.Time `json:"expires_at"`
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
Track int `json:"track"`
|
Track int `json:"track"`
|
||||||
Permissions []string `json:"permissions"`
|
Permissions []string `json:"permissions"`
|
||||||
|
InstitutionalTier *InstitutionalTier `json:"institutional_tier,omitempty"`
|
||||||
|
InstitutionName string `json:"institution_name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// walletAuthSignMessage returns the EIP-191 plaintext users sign during wallet login.
|
||||||
|
// Must stay in sync with frontend buildWalletMessage() in access.ts and explorer-spa.js.
|
||||||
|
func walletAuthSignMessage(nonce string) string {
|
||||||
|
return fmt.Sprintf("Sign this message to authenticate with DBIS Explorer.\n\nNonce: %s", nonce)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateNonce generates a random nonce for wallet authentication
|
// GenerateNonce generates a random nonce for wallet authentication
|
||||||
@@ -141,7 +190,7 @@ func (w *WalletAuth) AuthenticateWallet(ctx context.Context, req *WalletAuthRequ
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify signature
|
// Verify signature
|
||||||
message := fmt.Sprintf("Sign this message to authenticate with SolaceScan.\n\nNonce: %s", req.Nonce)
|
message := walletAuthSignMessage(req.Nonce)
|
||||||
messageHash := accounts.TextHash([]byte(message))
|
messageHash := accounts.TextHash([]byte(message))
|
||||||
|
|
||||||
sigBytes, err := decodeWalletSignature(req.Signature)
|
sigBytes, err := decodeWalletSignature(req.Signature)
|
||||||
@@ -182,17 +231,30 @@ func (w *WalletAuth) AuthenticateWallet(ctx context.Context, req *WalletAuthRequ
|
|||||||
// Get permissions for track
|
// Get permissions for track
|
||||||
permissions := getPermissionsForTrack(track)
|
permissions := getPermissionsForTrack(track)
|
||||||
|
|
||||||
return &WalletAuthResponse{
|
resp := &WalletAuthResponse{
|
||||||
Token: token,
|
Token: token,
|
||||||
ExpiresAt: expiresAt,
|
ExpiresAt: expiresAt,
|
||||||
Track: track,
|
Track: track,
|
||||||
Permissions: permissions,
|
Permissions: permissions,
|
||||||
}, nil
|
}
|
||||||
|
|
||||||
|
// Attach institutional membership info if present
|
||||||
|
store := NewMembershipStore(w.db)
|
||||||
|
if member, err := store.GetMemberByAddress(ctx, normalizedAddr); err == nil && member != nil {
|
||||||
|
resp.InstitutionalTier = &member.Tier
|
||||||
|
resp.InstitutionName = member.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getUserTrack gets the track level for a user address
|
// getUserTrack gets the track level for a user address.
|
||||||
|
// Resolution order:
|
||||||
|
// 1. Explicit per-address assignment in operator_roles (highest priority).
|
||||||
|
// 2. Institutional membership via institutional_member_wallets → tier default.
|
||||||
|
// 3. Fallback to Track 1 (public).
|
||||||
func (w *WalletAuth) getUserTrack(ctx context.Context, address string) (int, error) {
|
func (w *WalletAuth) getUserTrack(ctx context.Context, address string) (int, error) {
|
||||||
// Check if user exists in operator_roles (Track 4)
|
// 1. Check explicit per-address assignment in operator_roles
|
||||||
var track int
|
var track int
|
||||||
var approved bool
|
var approved bool
|
||||||
query := `SELECT track_level, approved FROM operator_roles WHERE address = $1`
|
query := `SELECT track_level, approved FROM operator_roles WHERE address = $1`
|
||||||
@@ -201,19 +263,37 @@ func (w *WalletAuth) getUserTrack(ctx context.Context, address string) (int, err
|
|||||||
return track, nil
|
return track, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user is approved for Track 2 or 3
|
// 2. Check institutional membership
|
||||||
// For now, default to Track 1 (public)
|
var tier string
|
||||||
// In production, you'd have an approval table
|
memberQuery := `
|
||||||
|
SELECT m.tier
|
||||||
|
FROM institutional_members m
|
||||||
|
JOIN institutional_member_wallets w ON w.member_id = m.id
|
||||||
|
WHERE LOWER(w.address) = LOWER($1) AND w.active = TRUE AND m.active = TRUE
|
||||||
|
`
|
||||||
|
err = w.db.QueryRow(ctx, memberQuery, address).Scan(&tier)
|
||||||
|
if err == nil {
|
||||||
|
return DefaultTrackForTier(InstitutionalTier(tier)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Default to Track 1 (public)
|
||||||
return 1, nil
|
return 1, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateJWT generates a JWT token with track claim
|
// generateJWT generates a JWT token with track, jti, exp, and iat claims.
|
||||||
|
// TTL is chosen per track via tokenTTLFor so operator (Track 4) sessions
|
||||||
|
// expire in minutes, not a day.
|
||||||
func (w *WalletAuth) generateJWT(address string, track int) (string, time.Time, error) {
|
func (w *WalletAuth) generateJWT(address string, track int) (string, time.Time, error) {
|
||||||
expiresAt := time.Now().Add(24 * time.Hour)
|
jti, err := newJTI()
|
||||||
|
if err != nil {
|
||||||
|
return "", time.Time{}, err
|
||||||
|
}
|
||||||
|
expiresAt := time.Now().Add(tokenTTLFor(track))
|
||||||
|
|
||||||
claims := jwt.MapClaims{
|
claims := jwt.MapClaims{
|
||||||
"address": address,
|
"address": address,
|
||||||
"track": track,
|
"track": track,
|
||||||
|
"jti": jti,
|
||||||
"exp": expiresAt.Unix(),
|
"exp": expiresAt.Unix(),
|
||||||
"iat": time.Now().Unix(),
|
"iat": time.Now().Unix(),
|
||||||
}
|
}
|
||||||
@@ -227,55 +307,182 @@ func (w *WalletAuth) generateJWT(address string, track int) (string, time.Time,
|
|||||||
return tokenString, expiresAt, nil
|
return tokenString, expiresAt, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateJWT validates a JWT token and returns the address and track
|
// ValidateJWT validates a JWT token and returns the address and track.
|
||||||
|
// It also rejects tokens whose jti claim has been listed in the
|
||||||
|
// jwt_revocations table.
|
||||||
func (w *WalletAuth) ValidateJWT(tokenString string) (string, int, error) {
|
func (w *WalletAuth) ValidateJWT(tokenString string) (string, int, error) {
|
||||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
address, track, _, _, err := w.parseJWT(tokenString)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have a database, enforce revocation and re-resolve the track
|
||||||
|
// (an operator revoking a wallet's Track 4 approval should not wait
|
||||||
|
// for the token to expire before losing the elevated permission).
|
||||||
|
if w.db != nil {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
jti, _ := w.jtiFromToken(tokenString)
|
||||||
|
if jti != "" {
|
||||||
|
revoked, revErr := w.isJTIRevoked(ctx, jti)
|
||||||
|
if revErr != nil && !errors.Is(revErr, ErrJWTRevocationStorageMissing) {
|
||||||
|
return "", 0, fmt.Errorf("failed to check revocation: %w", revErr)
|
||||||
|
}
|
||||||
|
if revoked {
|
||||||
|
return "", 0, ErrJWTRevoked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTrack, err := w.getUserTrack(ctx, address)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, fmt.Errorf("failed to resolve current track: %w", err)
|
||||||
|
}
|
||||||
|
if currentTrack < track {
|
||||||
|
track = currentTrack
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return address, track, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseJWT performs signature verification and claim extraction without
|
||||||
|
// any database round-trip. Shared between ValidateJWT and RefreshJWT.
|
||||||
|
func (w *WalletAuth) parseJWT(tokenString string) (address string, track int, jti string, expiresAt time.Time, err error) {
|
||||||
|
token, perr := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||||
}
|
}
|
||||||
return w.jwtSecret, nil
|
return w.jwtSecret, nil
|
||||||
})
|
})
|
||||||
|
if perr != nil {
|
||||||
if err != nil {
|
return "", 0, "", time.Time{}, fmt.Errorf("failed to parse token: %w", perr)
|
||||||
return "", 0, fmt.Errorf("failed to parse token: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !token.Valid {
|
if !token.Valid {
|
||||||
return "", 0, fmt.Errorf("invalid token")
|
return "", 0, "", time.Time{}, fmt.Errorf("invalid token")
|
||||||
}
|
}
|
||||||
|
|
||||||
claims, ok := token.Claims.(jwt.MapClaims)
|
claims, ok := token.Claims.(jwt.MapClaims)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", 0, fmt.Errorf("invalid token claims")
|
return "", 0, "", time.Time{}, fmt.Errorf("invalid token claims")
|
||||||
}
|
}
|
||||||
|
address, ok = claims["address"].(string)
|
||||||
address, ok := claims["address"].(string)
|
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", 0, fmt.Errorf("address not found in token")
|
return "", 0, "", time.Time{}, fmt.Errorf("address not found in token")
|
||||||
}
|
}
|
||||||
|
|
||||||
trackFloat, ok := claims["track"].(float64)
|
trackFloat, ok := claims["track"].(float64)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", 0, fmt.Errorf("track not found in token")
|
return "", 0, "", time.Time{}, fmt.Errorf("track not found in token")
|
||||||
}
|
}
|
||||||
|
track = int(trackFloat)
|
||||||
track := int(trackFloat)
|
if v, ok := claims["jti"].(string); ok {
|
||||||
if w.db == nil {
|
jti = v
|
||||||
return address, track, nil
|
|
||||||
}
|
}
|
||||||
|
if expFloat, ok := claims["exp"].(float64); ok {
|
||||||
|
expiresAt = time.Unix(int64(expFloat), 0)
|
||||||
|
}
|
||||||
|
return address, track, jti, expiresAt, nil
|
||||||
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
// jtiFromToken parses the jti claim without doing a fresh signature check.
|
||||||
defer cancel()
|
// It is a convenience helper for callers that have already validated the
|
||||||
|
// token through parseJWT.
|
||||||
currentTrack, err := w.getUserTrack(ctx, address)
|
func (w *WalletAuth) jtiFromToken(tokenString string) (string, error) {
|
||||||
|
parser := jwt.Parser{}
|
||||||
|
token, _, err := parser.ParseUnverified(tokenString, jwt.MapClaims{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", 0, fmt.Errorf("failed to resolve current track: %w", err)
|
return "", err
|
||||||
}
|
}
|
||||||
if currentTrack < track {
|
claims, ok := token.Claims.(jwt.MapClaims)
|
||||||
track = currentTrack
|
if !ok {
|
||||||
|
return "", fmt.Errorf("invalid claims")
|
||||||
|
}
|
||||||
|
v, _ := claims["jti"].(string)
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isJTIRevoked checks whether the given jti appears in jwt_revocations.
|
||||||
|
// Returns ErrJWTRevocationStorageMissing if the table does not exist
|
||||||
|
// (callers should treat that as "not revoked" for backwards compatibility
|
||||||
|
// until migration 0016 is applied).
|
||||||
|
func (w *WalletAuth) isJTIRevoked(ctx context.Context, jti string) (bool, error) {
|
||||||
|
var exists bool
|
||||||
|
err := w.db.QueryRow(ctx,
|
||||||
|
`SELECT EXISTS(SELECT 1 FROM jwt_revocations WHERE jti = $1)`, jti,
|
||||||
|
).Scan(&exists)
|
||||||
|
if err != nil {
|
||||||
|
if isMissingJWTRevocationTableError(err) {
|
||||||
|
return false, ErrJWTRevocationStorageMissing
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return exists, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeJWT records the token's jti in jwt_revocations. Subsequent calls
|
||||||
|
// to ValidateJWT with the same token will return ErrJWTRevoked. Idempotent
|
||||||
|
// on duplicate jti.
|
||||||
|
func (w *WalletAuth) RevokeJWT(ctx context.Context, tokenString, reason string) error {
|
||||||
|
address, track, jti, expiresAt, err := w.parseJWT(tokenString)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if jti == "" {
|
||||||
|
// Legacy tokens issued before PR #8 don't carry a jti; there is
|
||||||
|
// nothing to revoke server-side. Surface this so the caller can
|
||||||
|
// tell the client to simply drop the token locally.
|
||||||
|
return fmt.Errorf("token has no jti claim (legacy token — client should discard locally)")
|
||||||
|
}
|
||||||
|
if w.db == nil {
|
||||||
|
return fmt.Errorf("wallet auth has no database; cannot revoke")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(reason) == "" {
|
||||||
|
reason = "logout"
|
||||||
|
}
|
||||||
|
_, err = w.db.Exec(ctx,
|
||||||
|
`INSERT INTO jwt_revocations (jti, address, track, token_expires_at, reason)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (jti) DO NOTHING`,
|
||||||
|
jti, address, track, expiresAt, reason,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if isMissingJWTRevocationTableError(err) {
|
||||||
|
return ErrJWTRevocationStorageMissing
|
||||||
|
}
|
||||||
|
return fmt.Errorf("record revocation: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshJWT issues a new token for the same address+track if the current
|
||||||
|
// token is valid (signed, unexpired, not revoked) and revokes the current
|
||||||
|
// token so it cannot be replayed. Returns the new token and its exp.
|
||||||
|
func (w *WalletAuth) RefreshJWT(ctx context.Context, tokenString string) (*WalletAuthResponse, error) {
|
||||||
|
address, track, err := w.ValidateJWT(tokenString)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Revoke the old token before issuing a new one. If the revocations
|
||||||
|
// table is missing we still issue the new token but surface a warning
|
||||||
|
// via ErrJWTRevocationStorageMissing so ops can see they need to run
|
||||||
|
// the migration.
|
||||||
|
var revokeErr error
|
||||||
|
if w.db != nil {
|
||||||
|
revokeErr = w.RevokeJWT(ctx, tokenString, "refresh")
|
||||||
|
if revokeErr != nil && !errors.Is(revokeErr, ErrJWTRevocationStorageMissing) {
|
||||||
|
return nil, revokeErr
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return address, track, nil
|
newToken, expiresAt, err := w.generateJWT(address, track)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &WalletAuthResponse{
|
||||||
|
Token: newToken,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
Track: track,
|
||||||
|
Permissions: getPermissionsForTrack(track),
|
||||||
|
}, revokeErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeWalletSignature(signature string) ([]byte, error) {
|
func decodeWalletSignature(signature string) ([]byte, error) {
|
||||||
|
|||||||
@@ -1,11 +1,46 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ethereum/go-ethereum/accounts"
|
||||||
|
"github.com/ethereum/go-ethereum/crypto"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestWalletAuthSignMessageMatchesFrontend(t *testing.T) {
|
||||||
|
nonce := "abc123def456"
|
||||||
|
require.Equal(
|
||||||
|
t,
|
||||||
|
"Sign this message to authenticate with DBIS Explorer.\n\nNonce: abc123def456",
|
||||||
|
walletAuthSignMessage(nonce),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthenticateWalletRecoversSignerFromFrontendMessage(t *testing.T) {
|
||||||
|
privateKey, err := crypto.GenerateKey()
|
||||||
|
require.NoError(t, err)
|
||||||
|
address := crypto.PubkeyToAddress(privateKey.PublicKey).Hex()
|
||||||
|
nonce := "test-nonce-001"
|
||||||
|
|
||||||
|
message := walletAuthSignMessage(nonce)
|
||||||
|
messageHash := accounts.TextHash([]byte(message))
|
||||||
|
signature, err := crypto.Sign(messageHash, privateKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
signature[64] += 27
|
||||||
|
|
||||||
|
sigBytes := make([]byte, len(signature))
|
||||||
|
copy(sigBytes, signature)
|
||||||
|
if sigBytes[64] >= 27 {
|
||||||
|
sigBytes[64] -= 27
|
||||||
|
}
|
||||||
|
pubKey, err := crypto.SigToPub(messageHash, sigBytes)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, address, crypto.PubkeyToAddress(*pubKey).Hex())
|
||||||
|
}
|
||||||
|
|
||||||
func TestDecodeWalletSignatureRejectsMalformedValues(t *testing.T) {
|
func TestDecodeWalletSignatureRejectsMalformedValues(t *testing.T) {
|
||||||
_, err := decodeWalletSignature("deadbeef")
|
_, err := decodeWalletSignature("deadbeef")
|
||||||
require.ErrorContains(t, err, "signature must start with 0x")
|
require.ErrorContains(t, err, "signature must start with 0x")
|
||||||
@@ -26,3 +61,59 @@ func TestValidateJWTReturnsClaimsWhenDBUnavailable(t *testing.T) {
|
|||||||
require.Equal(t, "0x4A666F96fC8764181194447A7dFdb7d471b301C8", address)
|
require.Equal(t, "0x4A666F96fC8764181194447A7dFdb7d471b301C8", address)
|
||||||
require.Equal(t, 4, track)
|
require.Equal(t, 4, track)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTokenTTLForTrack4IsShort(t *testing.T) {
|
||||||
|
// Track 4 (operator) must have a TTL <= 1h — that is the headline
|
||||||
|
// tightening promised by completion criterion 3 (JWT hygiene).
|
||||||
|
ttl := tokenTTLFor(4)
|
||||||
|
require.LessOrEqual(t, ttl, time.Hour, "track 4 TTL must be <= 1h")
|
||||||
|
require.Greater(t, ttl, time.Duration(0), "track 4 TTL must be positive")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTokenTTLForTrack1Track2Track3AreReasonable(t *testing.T) {
|
||||||
|
// Non-operator tracks are allowed longer sessions, but still bounded
|
||||||
|
// at 12h so a stale laptop tab doesn't carry a week-old token.
|
||||||
|
for _, track := range []int{1, 2, 3} {
|
||||||
|
ttl := tokenTTLFor(track)
|
||||||
|
require.Greater(t, ttl, time.Duration(0), "track %d TTL must be > 0", track)
|
||||||
|
require.LessOrEqual(t, ttl, 12*time.Hour, "track %d TTL must be <= 12h", track)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeneratedJWTCarriesJTIClaim(t *testing.T) {
|
||||||
|
// Revocation keys on jti. A token issued without one is unrevokable
|
||||||
|
// and must not be produced.
|
||||||
|
a := NewWalletAuth(nil, []byte("test-secret"))
|
||||||
|
token, _, err := a.generateJWT("0x4A666F96fC8764181194447A7dFdb7d471b301C8", 2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
jti, err := a.jtiFromToken(token)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, jti, "generated JWT must carry a jti claim")
|
||||||
|
require.Len(t, jti, 32, "jti should be 16 random bytes hex-encoded (32 chars)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeneratedJWTExpIsTrackAppropriate(t *testing.T) {
|
||||||
|
a := NewWalletAuth(nil, []byte("test-secret"))
|
||||||
|
for _, track := range []int{1, 2, 3, 4} {
|
||||||
|
_, expiresAt, err := a.generateJWT("0x4A666F96fC8764181194447A7dFdb7d471b301C8", track)
|
||||||
|
require.NoError(t, err)
|
||||||
|
want := tokenTTLFor(track)
|
||||||
|
// allow a couple-second slack for test execution
|
||||||
|
actual := time.Until(expiresAt)
|
||||||
|
require.InDelta(t, want.Seconds(), actual.Seconds(), 5.0,
|
||||||
|
"track %d exp should be ~%s from now, got %s", track, want, actual)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRevokeJWTWithoutDBReturnsError(t *testing.T) {
|
||||||
|
// With w.db == nil, revocation has nowhere to write — the call must
|
||||||
|
// fail loudly so callers don't silently assume a token was revoked.
|
||||||
|
a := NewWalletAuth(nil, []byte("test-secret"))
|
||||||
|
token, _, err := a.generateJWT("0x4A666F96fC8764181194447A7dFdb7d471b301C8", 4)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = a.RevokeJWT(context.Background(), token, "test")
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "no database")
|
||||||
|
}
|
||||||
|
|||||||
Binary file not shown.
BIN
backend/cmd
BIN
backend/cmd
Binary file not shown.
@@ -25,7 +25,9 @@
|
|||||||
"https://explorer.d-bis.org"
|
"https://explorer.d-bis.org"
|
||||||
],
|
],
|
||||||
"iconUrls": [
|
"iconUrls": [
|
||||||
"https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png"
|
"https://explorer.d-bis.org/token-icons/chain-138.png",
|
||||||
|
"https://explorer.d-bis.org/api/v1/report/logo/chain-138",
|
||||||
|
"https://explorer.d-bis.org/favicon.ico"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
97
backend/config/rpc_products.yaml
Normal file
97
backend/config/rpc_products.yaml
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# Chain 138 RPC access product catalog.
|
||||||
|
#
|
||||||
|
# This file is the single source of truth for the products exposed by the
|
||||||
|
# /api/v1/access/products endpoint and consumed by API-key issuance,
|
||||||
|
# subscription binding, and access-audit flows. Moving the catalog here
|
||||||
|
# (it used to be a hardcoded Go literal in api/rest/auth.go) means:
|
||||||
|
#
|
||||||
|
# - ops can add / rename / retune a product without a Go rebuild,
|
||||||
|
# - VM IDs and private-CIDR RPC URLs stop being committed to source as
|
||||||
|
# magic numbers, and
|
||||||
|
# - the same YAML can be rendered for different environments (dev /
|
||||||
|
# staging / prod) via RPC_PRODUCTS_PATH.
|
||||||
|
#
|
||||||
|
# Path resolution at startup:
|
||||||
|
# 1. $RPC_PRODUCTS_PATH if set (absolute or relative to the working dir),
|
||||||
|
# 2. $EXPLORER_BACKEND_DIR/config/rpc_products.yaml if that env var is set,
|
||||||
|
# 3. the first of <cwd>/backend/config/rpc_products.yaml or
|
||||||
|
# <cwd>/config/rpc_products.yaml that exists,
|
||||||
|
# 4. the compiled-in fallback slice (legacy behaviour; logs a warning).
|
||||||
|
#
|
||||||
|
# Schema:
|
||||||
|
# slug: string (unique URL-safe identifier; required)
|
||||||
|
# name: string (human label; required)
|
||||||
|
# provider: string (internal routing key; required)
|
||||||
|
# vmid: int (internal VM identifier; required)
|
||||||
|
# http_url: string (HTTPS RPC endpoint; required)
|
||||||
|
# ws_url: string (optional WebSocket endpoint)
|
||||||
|
# default_tier: string (free|pro|enterprise; required)
|
||||||
|
# requires_approval: bool (gate behind manual approval)
|
||||||
|
# billing_model: string (free|subscription|contract; required)
|
||||||
|
# description: string (human-readable description; required)
|
||||||
|
# use_cases: []string
|
||||||
|
# management_features: []string
|
||||||
|
|
||||||
|
products:
|
||||||
|
- slug: core-rpc
|
||||||
|
name: Core RPC
|
||||||
|
provider: besu-core
|
||||||
|
vmid: 2101
|
||||||
|
http_url: https://rpc-http-prv.d-bis.org
|
||||||
|
ws_url: wss://rpc-ws-prv.d-bis.org
|
||||||
|
default_tier: enterprise
|
||||||
|
requires_approval: true
|
||||||
|
billing_model: contract
|
||||||
|
description: >-
|
||||||
|
Private Chain 138 Core RPC for operator-grade administration and
|
||||||
|
sensitive workloads.
|
||||||
|
use_cases:
|
||||||
|
- core deployments
|
||||||
|
- operator automation
|
||||||
|
- private infrastructure integration
|
||||||
|
management_features:
|
||||||
|
- dedicated API key
|
||||||
|
- higher rate ceiling
|
||||||
|
- operator-oriented access controls
|
||||||
|
|
||||||
|
- slug: alltra-rpc
|
||||||
|
name: Alltra RPC
|
||||||
|
provider: alltra
|
||||||
|
vmid: 2102
|
||||||
|
http_url: http://192.168.11.212:8545
|
||||||
|
ws_url: ws://192.168.11.212:8546
|
||||||
|
default_tier: pro
|
||||||
|
requires_approval: false
|
||||||
|
billing_model: subscription
|
||||||
|
description: >-
|
||||||
|
Dedicated Alltra-managed RPC lane for partner traffic, subscription
|
||||||
|
access, and API-key-gated usage.
|
||||||
|
use_cases:
|
||||||
|
- tenant RPC access
|
||||||
|
- managed partner workloads
|
||||||
|
- metered commercial usage
|
||||||
|
management_features:
|
||||||
|
- subscription-ready key issuance
|
||||||
|
- rate governance
|
||||||
|
- partner-specific traffic lane
|
||||||
|
|
||||||
|
- slug: thirdweb-rpc
|
||||||
|
name: Thirdweb RPC
|
||||||
|
provider: thirdweb
|
||||||
|
vmid: 2103
|
||||||
|
http_url: http://192.168.11.217:8545
|
||||||
|
ws_url: ws://192.168.11.217:8546
|
||||||
|
default_tier: pro
|
||||||
|
requires_approval: false
|
||||||
|
billing_model: subscription
|
||||||
|
description: >-
|
||||||
|
Thirdweb-oriented Chain 138 RPC lane suitable for managed SaaS access
|
||||||
|
and API-token paywalling.
|
||||||
|
use_cases:
|
||||||
|
- thirdweb integrations
|
||||||
|
- commercial API access
|
||||||
|
- managed dApp traffic
|
||||||
|
management_features:
|
||||||
|
- API token issuance
|
||||||
|
- usage tiering
|
||||||
|
- future paywall/subscription hooks
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Migration 0016_jwt_revocations.down.sql
|
||||||
|
DROP INDEX IF EXISTS idx_jwt_revocations_expires;
|
||||||
|
DROP INDEX IF EXISTS idx_jwt_revocations_address;
|
||||||
|
DROP TABLE IF EXISTS jwt_revocations;
|
||||||
30
backend/database/migrations/0016_jwt_revocations.up.sql
Normal file
30
backend/database/migrations/0016_jwt_revocations.up.sql
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
-- Migration 0016_jwt_revocations.up.sql
|
||||||
|
--
|
||||||
|
-- Introduces server-side JWT revocation for the SolaceScan backend.
|
||||||
|
--
|
||||||
|
-- Up to this migration, tokens issued by /api/v1/auth/wallet were simply
|
||||||
|
-- signed and returned; the backend had no way to invalidate a token before
|
||||||
|
-- its exp claim short of rotating the JWT_SECRET (which would invalidate
|
||||||
|
-- every outstanding session). PR #8 introduces per-token revocation keyed
|
||||||
|
-- on the `jti` claim.
|
||||||
|
--
|
||||||
|
-- The table is append-only: a row exists iff that jti has been revoked.
|
||||||
|
-- ValidateJWT consults the table on every request; the primary key on
|
||||||
|
-- (jti) keeps lookups O(log n) and deduplicates repeated logout calls.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS jwt_revocations (
|
||||||
|
jti TEXT PRIMARY KEY,
|
||||||
|
address TEXT NOT NULL,
|
||||||
|
track INT NOT NULL,
|
||||||
|
-- original exp of the revoked token, so a background janitor can
|
||||||
|
-- reap rows after they can no longer matter.
|
||||||
|
token_expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
revoked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
reason TEXT NOT NULL DEFAULT 'logout'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_jwt_revocations_address
|
||||||
|
ON jwt_revocations (address);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_jwt_revocations_expires
|
||||||
|
ON jwt_revocations (token_expires_at);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- 0017_institutional_membership.down.sql
|
||||||
|
DROP TRIGGER IF EXISTS update_institutional_members_updated_at ON institutional_members;
|
||||||
|
DROP TABLE IF EXISTS institutional_member_wallets;
|
||||||
|
DROP TABLE IF EXISTS institutional_members;
|
||||||
|
DROP TYPE IF EXISTS institutional_tier;
|
||||||
178
backend/database/migrations/0017_institutional_membership.up.sql
Normal file
178
backend/database/migrations/0017_institutional_membership.up.sql
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
-- 0017_institutional_membership.up.sql
|
||||||
|
--
|
||||||
|
-- Adds institutional membership tables and seeds the canonical DBIS member
|
||||||
|
-- directory. The tier taxonomy comes from https://d-bis.org/members#tiers
|
||||||
|
-- with corrections per institutional review (2026-04).
|
||||||
|
|
||||||
|
-- Tier enum
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE institutional_tier AS ENUM (
|
||||||
|
'sovereign_central_bank',
|
||||||
|
'global_family_office',
|
||||||
|
'settlement_member',
|
||||||
|
'infrastructure_operator',
|
||||||
|
'oversight_judicial',
|
||||||
|
'delegated_authority',
|
||||||
|
'standards_body'
|
||||||
|
);
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Members directory
|
||||||
|
CREATE TABLE IF NOT EXISTS institutional_members (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
slug VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
abbreviation VARCHAR(16) NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
tier institutional_tier NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
jurisdiction TEXT,
|
||||||
|
lei VARCHAR(20),
|
||||||
|
latitude DOUBLE PRECISION,
|
||||||
|
longitude DOUBLE PRECISION,
|
||||||
|
map_label TEXT,
|
||||||
|
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_institutional_members_tier ON institutional_members(tier);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_institutional_members_active ON institutional_members(active);
|
||||||
|
|
||||||
|
-- Junction: wallet addresses linked to institutional members
|
||||||
|
CREATE TABLE IF NOT EXISTS institutional_member_wallets (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
member_id INTEGER NOT NULL REFERENCES institutional_members(id) ON DELETE CASCADE,
|
||||||
|
address VARCHAR(42) NOT NULL,
|
||||||
|
label TEXT,
|
||||||
|
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
UNIQUE(member_id, address)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_imw_address ON institutional_member_wallets(address);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_imw_member_id ON institutional_member_wallets(member_id);
|
||||||
|
|
||||||
|
-- Triggers
|
||||||
|
CREATE TRIGGER update_institutional_members_updated_at
|
||||||
|
BEFORE UPDATE ON institutional_members
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Seed data — canonical member directory
|
||||||
|
-- ============================================================
|
||||||
|
-- Corrections from 2026-04 review:
|
||||||
|
-- • MLFO is a Global Family Office, NOT a central bank
|
||||||
|
-- • BIS Innovation Hub removed from directory entirely
|
||||||
|
-- • Added: ICCC, SAID, PANDA, Order of Hospitallers (XOM)
|
||||||
|
-- • Placeholder rows for BRICS founding central banks
|
||||||
|
|
||||||
|
INSERT INTO institutional_members
|
||||||
|
(slug, abbreviation, name, tier, description, jurisdiction, lei, latitude, longitude, map_label)
|
||||||
|
VALUES
|
||||||
|
-- Existing (corrected)
|
||||||
|
('omnl', 'OMNL', 'Organisation Mondiale du Numérique',
|
||||||
|
'sovereign_central_bank',
|
||||||
|
'Participating central bank — OMNL Head Office ledger and journal operator (Fineract / OMNL tenant). ARIN OrgId: OMNL.',
|
||||||
|
'International / participating monetary union', '98450070C57395F6B906',
|
||||||
|
39.61, -104.89, 'Greenwood Village, CO'),
|
||||||
|
|
||||||
|
('mlfo', 'MLFO', 'Mann Li Family Office',
|
||||||
|
'global_family_office',
|
||||||
|
'Founding family office (L.P.B.C., Colorado Entity 20241969162). Capital structure sponsor and BIS debit performance beneficiary. Registered agent: Pandora C. Walker.',
|
||||||
|
'US-CO (Colorado)', NULL,
|
||||||
|
40.02, -105.27, 'Boulder, CO'),
|
||||||
|
|
||||||
|
('defi-oracle', 'DFO', 'DeFi Oracle',
|
||||||
|
'infrastructure_operator',
|
||||||
|
'Infrastructure operator for Chain 138 ecosystem. Manages smart contract deployment (131 contracts), cross-chain bridges, PMM pools, and wallet integrations (MetaMask Snap, Ledger Live).',
|
||||||
|
'US-CO (Colorado)', NULL,
|
||||||
|
39.61, -104.89, 'Greenwood Village, CO'),
|
||||||
|
|
||||||
|
-- Added entities
|
||||||
|
('iccc', 'ICCC', 'International Criminal Court of Commerce',
|
||||||
|
'oversight_judicial',
|
||||||
|
'International court with oversight authority over DBIS ecosystem commercial disputes and enforcement.',
|
||||||
|
'International', NULL,
|
||||||
|
NULL, NULL, NULL),
|
||||||
|
|
||||||
|
('said', 'SAID', 'SAID',
|
||||||
|
'standards_body',
|
||||||
|
'Standards and identity body within the DBIS institutional framework.',
|
||||||
|
'International', NULL,
|
||||||
|
NULL, NULL, NULL),
|
||||||
|
|
||||||
|
('panda', 'PANDA', 'PANDA',
|
||||||
|
'standards_body',
|
||||||
|
'Standards and coordination body within the DBIS institutional framework.',
|
||||||
|
'International', NULL,
|
||||||
|
NULL, NULL, NULL),
|
||||||
|
|
||||||
|
('xom', 'XOM', 'Sovereign Military Hospitaller Order of St. John of Jerusalem of Rhodes and of Malta',
|
||||||
|
'delegated_authority',
|
||||||
|
'The sovereign entity (Order of Hospitallers) extending DBIS the agency authority under which it operates. Recognised UN observer state.',
|
||||||
|
'International (Rome)', NULL,
|
||||||
|
41.90, 12.48, 'Rome, Italy'),
|
||||||
|
|
||||||
|
-- BRICS founding member central banks (representative set)
|
||||||
|
('cb-brazil', 'BCB', 'Banco Central do Brasil',
|
||||||
|
'sovereign_central_bank',
|
||||||
|
'Central Bank of Brazil — BRICS founding member.',
|
||||||
|
'Brazil', NULL,
|
||||||
|
-15.79, -47.88, 'Brasília, Brazil'),
|
||||||
|
|
||||||
|
('cb-russia', 'CBR', 'Central Bank of the Russian Federation',
|
||||||
|
'sovereign_central_bank',
|
||||||
|
'Bank of Russia — BRICS founding member.',
|
||||||
|
'Russia', NULL,
|
||||||
|
55.76, 37.62, 'Moscow, Russia'),
|
||||||
|
|
||||||
|
('cb-india', 'RBI', 'Reserve Bank of India',
|
||||||
|
'sovereign_central_bank',
|
||||||
|
'Reserve Bank of India — BRICS founding member.',
|
||||||
|
'India', NULL,
|
||||||
|
18.93, 72.83, 'Mumbai, India'),
|
||||||
|
|
||||||
|
('cb-china', 'PBOC', 'People''s Bank of China',
|
||||||
|
'sovereign_central_bank',
|
||||||
|
'People''s Bank of China — BRICS founding member.',
|
||||||
|
'China', NULL,
|
||||||
|
39.91, 116.39, 'Beijing, China'),
|
||||||
|
|
||||||
|
('cb-south-africa', 'SARB', 'South African Reserve Bank',
|
||||||
|
'sovereign_central_bank',
|
||||||
|
'South African Reserve Bank — BRICS founding member.',
|
||||||
|
'South Africa', NULL,
|
||||||
|
-25.75, 28.19, 'Pretoria, South Africa'),
|
||||||
|
|
||||||
|
-- BRICS expanded members (2024+)
|
||||||
|
('cb-egypt', 'CBE', 'Central Bank of Egypt',
|
||||||
|
'sovereign_central_bank',
|
||||||
|
'Central Bank of Egypt — BRICS member (2024).',
|
||||||
|
'Egypt', NULL,
|
||||||
|
30.04, 31.24, 'Cairo, Egypt'),
|
||||||
|
|
||||||
|
('cb-ethiopia', 'NBE', 'National Bank of Ethiopia',
|
||||||
|
'sovereign_central_bank',
|
||||||
|
'National Bank of Ethiopia — BRICS member (2024).',
|
||||||
|
'Ethiopia', NULL,
|
||||||
|
9.02, 38.75, 'Addis Ababa, Ethiopia'),
|
||||||
|
|
||||||
|
('cb-iran', 'CBI', 'Central Bank of the Islamic Republic of Iran',
|
||||||
|
'sovereign_central_bank',
|
||||||
|
'Central Bank of Iran — BRICS member (2024).',
|
||||||
|
'Iran', NULL,
|
||||||
|
35.70, 51.42, 'Tehran, Iran'),
|
||||||
|
|
||||||
|
('cb-uae', 'CBUAE', 'Central Bank of the UAE',
|
||||||
|
'sovereign_central_bank',
|
||||||
|
'Central Bank of the UAE — BRICS member (2024).',
|
||||||
|
'United Arab Emirates', NULL,
|
||||||
|
24.45, 54.65, 'Abu Dhabi, UAE'),
|
||||||
|
|
||||||
|
('cb-saudi-arabia', 'SAMA', 'Saudi Central Bank',
|
||||||
|
'sovereign_central_bank',
|
||||||
|
'Saudi Central Bank (formerly SAMA) — BRICS member (2024).',
|
||||||
|
'Saudi Arabia', NULL,
|
||||||
|
24.71, 46.68, 'Riyadh, Saudi Arabia')
|
||||||
|
ON CONFLICT (slug) DO NOTHING;
|
||||||
@@ -118,3 +118,19 @@ func GetAllFeatures() map[string]FeatureFlag {
|
|||||||
return FeatureFlags
|
return FeatureFlags
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TrackLabel returns a human-readable label for an access track number.
|
||||||
|
func TrackLabel(track int) string {
|
||||||
|
switch track {
|
||||||
|
case 1:
|
||||||
|
return "Explorer"
|
||||||
|
case 2:
|
||||||
|
return "Enhanced Explorer"
|
||||||
|
case 3:
|
||||||
|
return "Analytics"
|
||||||
|
case 4:
|
||||||
|
return "Operator"
|
||||||
|
default:
|
||||||
|
return "Unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ require (
|
|||||||
github.com/redis/go-redis/v9 v9.17.2
|
github.com/redis/go-redis/v9 v9.17.2
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
golang.org/x/crypto v0.36.0
|
golang.org/x/crypto v0.36.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -51,6 +52,5 @@ require (
|
|||||||
golang.org/x/text v0.23.0 // indirect
|
golang.org/x/text v0.23.0 // indirect
|
||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
|
||||||
google.golang.org/protobuf v1.33.0 // indirect
|
google.golang.org/protobuf v1.33.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
|
||||||
rsc.io/tmplfunc v0.0.3 // indirect
|
rsc.io/tmplfunc v0.0.3 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -87,9 +87,12 @@ func (t *Tracer) storeTrace(ctx context.Context, txHash common.Hash, blockNumber
|
|||||||
) PARTITION BY LIST (chain_id)
|
) PARTITION BY LIST (chain_id)
|
||||||
`
|
`
|
||||||
|
|
||||||
_, err := t.db.Exec(ctx, query)
|
// Ensure the table exists. The CREATE is idempotent; a failure here is
|
||||||
if err != nil {
|
// best-effort because races with other indexer replicas can surface as
|
||||||
// Table might already exist
|
// transient "already exists" errors. The follow-up INSERT will surface
|
||||||
|
// any real schema problem.
|
||||||
|
if _, err := t.db.Exec(ctx, query); err != nil {
|
||||||
|
_ = err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert trace
|
// Insert trace
|
||||||
|
|||||||
@@ -86,7 +86,14 @@ func (bi *BlockIndexer) IndexLatestBlocks(ctx context.Context, count int) error
|
|||||||
|
|
||||||
latestBlock := header.Number.Uint64()
|
latestBlock := header.Number.Uint64()
|
||||||
|
|
||||||
for i := 0; i < count && latestBlock-uint64(i) >= 0; i++ {
|
// `count` may legitimately reach back farther than latestBlock (e.g.
|
||||||
|
// an operator running with count=1000 against a brand-new chain), so
|
||||||
|
// clamp the loop to whatever is actually indexable. The previous
|
||||||
|
// "latestBlock-uint64(i) >= 0" guard was a no-op on an unsigned type.
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
if uint64(i) > latestBlock {
|
||||||
|
break
|
||||||
|
}
|
||||||
blockNum := latestBlock - uint64(i)
|
blockNum := latestBlock - uint64(i)
|
||||||
if err := bi.IndexBlock(ctx, blockNum); err != nil {
|
if err := bi.IndexBlock(ctx, blockNum); err != nil {
|
||||||
// Log error but continue
|
// Log error but continue
|
||||||
|
|||||||
17
backend/staticcheck.conf
Normal file
17
backend/staticcheck.conf
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
checks = [
|
||||||
|
"all",
|
||||||
|
# Style / unused nits. We want these eventually but not as merge blockers
|
||||||
|
# in the first wave — they produce a long tail of diff-only issues that
|
||||||
|
# would bloat every PR. Re-enable in a dedicated cleanup PR.
|
||||||
|
"-ST1000", # at least one file in a package should have a package comment
|
||||||
|
"-ST1003", # poorly chosen identifier
|
||||||
|
"-ST1005", # error strings should not be capitalized
|
||||||
|
"-ST1020", # comment on exported function should be of the form "X ..."
|
||||||
|
"-ST1021", # comment on exported type should be of the form "X ..."
|
||||||
|
"-ST1022", # comment on exported var/const should be of the form "X ..."
|
||||||
|
"-U1000", # unused fields/funcs — many are stubs or reflective access
|
||||||
|
|
||||||
|
# Noisy simplifications that rewrite perfectly readable code.
|
||||||
|
"-S1016", # should use type conversion instead of struct literal
|
||||||
|
"-S1031", # unnecessary nil check around range — defensive anyway
|
||||||
|
]
|
||||||
@@ -6,6 +6,15 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ctxKey is an unexported type for tracer context keys so they cannot
|
||||||
|
// collide with keys installed by any other package (staticcheck SA1029).
|
||||||
|
type ctxKey string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ctxKeyTraceID ctxKey = "trace_id"
|
||||||
|
ctxKeySpanID ctxKey = "span_id"
|
||||||
|
)
|
||||||
|
|
||||||
// Tracer provides distributed tracing
|
// Tracer provides distributed tracing
|
||||||
type Tracer struct {
|
type Tracer struct {
|
||||||
serviceName string
|
serviceName string
|
||||||
@@ -48,9 +57,8 @@ func (t *Tracer) StartSpan(ctx context.Context, name string) (*Span, context.Con
|
|||||||
Logs: []LogEntry{},
|
Logs: []LogEntry{},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add to context
|
ctx = context.WithValue(ctx, ctxKeyTraceID, traceID)
|
||||||
ctx = context.WithValue(ctx, "trace_id", traceID)
|
ctx = context.WithValue(ctx, ctxKeySpanID, spanID)
|
||||||
ctx = context.WithValue(ctx, "span_id", spanID)
|
|
||||||
|
|
||||||
return span, ctx
|
return span, ctx
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,35 +3,148 @@ package wallet
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// WalletConnect handles WalletConnect v2 integration
|
const (
|
||||||
|
WalletConnectStatusStub = "stub"
|
||||||
|
WalletConnectStatusClient = "client"
|
||||||
|
WalletConnectStatusDisabled = "disabled"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config describes the public WalletConnect v2 posture exposed to clients.
|
||||||
|
type Config struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
ProjectID string `json:"projectId"`
|
||||||
|
RelayURL string `json:"relayUrl"`
|
||||||
|
MetadataURL string `json:"metadataUrl"`
|
||||||
|
RequiredNamespaces []string `json:"requiredNamespaces"`
|
||||||
|
SupportedChains []int `json:"supportedChains"`
|
||||||
|
FallbackAuth string `json:"fallbackAuth"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
UpdatedAt string `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConnectResponse is returned while WalletConnect session bridging remains a stub.
|
||||||
|
type ConnectResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
URI string `json:"uri,omitempty"`
|
||||||
|
SessionID string `json:"sessionId,omitempty"`
|
||||||
|
ExpiresAt string `json:"expiresAt,omitempty"`
|
||||||
|
FallbackAuth string `json:"fallbackAuth"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session represents a wallet session snapshot for future WalletConnect integration.
|
||||||
|
type Session struct {
|
||||||
|
SessionID string `json:"sessionId"`
|
||||||
|
Address string `json:"address,omitempty"`
|
||||||
|
ChainID int `json:"chainId,omitempty"`
|
||||||
|
Connected bool `json:"connected"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WalletConnect handles WalletConnect v2 integration posture for the explorer API.
|
||||||
type WalletConnect struct {
|
type WalletConnect struct {
|
||||||
projectID string
|
projectID string
|
||||||
|
relayURL string
|
||||||
|
chainID int
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWalletConnect creates a new WalletConnect handler
|
// NewWalletConnect creates a WalletConnect handler using deployment env vars.
|
||||||
func NewWalletConnect(projectID string) *WalletConnect {
|
func NewWalletConnect(chainID int) *WalletConnect {
|
||||||
return &WalletConnect{projectID: projectID}
|
projectID := strings.TrimSpace(os.Getenv("WALLETCONNECT_PROJECT_ID"))
|
||||||
|
relayURL := strings.TrimSpace(os.Getenv("WALLETCONNECT_RELAY_URL"))
|
||||||
|
if relayURL == "" {
|
||||||
|
relayURL = "wss://relay.walletconnect.org"
|
||||||
|
}
|
||||||
|
return &WalletConnect{
|
||||||
|
projectID: projectID,
|
||||||
|
relayURL: relayURL,
|
||||||
|
chainID: chainID,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect initiates a wallet connection
|
func (wc *WalletConnect) enabled() bool {
|
||||||
func (wc *WalletConnect) Connect(ctx context.Context) (string, error) {
|
return wc.projectID != ""
|
||||||
// Implementation would use WalletConnect v2 SDK
|
|
||||||
// Returns connection URI for QR code display
|
|
||||||
return "", fmt.Errorf("not implemented - requires WalletConnect SDK")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Session represents a wallet session
|
// PublicConfig returns the read-only WalletConnect config surface for clients.
|
||||||
type Session struct {
|
func (wc *WalletConnect) PublicConfig() Config {
|
||||||
Address string
|
status := WalletConnectStatusDisabled
|
||||||
ChainID int
|
if wc.enabled() {
|
||||||
Connected bool
|
status = WalletConnectStatusClient
|
||||||
|
}
|
||||||
|
return Config{
|
||||||
|
Status: status,
|
||||||
|
Enabled: wc.enabled(),
|
||||||
|
ProjectID: wc.projectID,
|
||||||
|
RelayURL: wc.relayURL,
|
||||||
|
MetadataURL: "/api/v1/walletconnect/metadata",
|
||||||
|
RequiredNamespaces: []string{"eip155"},
|
||||||
|
SupportedChains: []int{wc.chainID, 1},
|
||||||
|
FallbackAuth: "/api/v1/auth/wallet",
|
||||||
|
Message: wc.publicMessage(),
|
||||||
|
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSession gets current wallet session
|
func (wc *WalletConnect) publicMessage() string {
|
||||||
func (wc *WalletConnect) GetSession(ctx context.Context, sessionID string) (*Session, error) {
|
if wc.enabled() {
|
||||||
// Implementation would retrieve session from WalletConnect
|
return "WalletConnect v2 is enabled. Use the WalletConnect button on /wallet for mobile QR pairing; browser extension wallets can continue using /api/v1/auth/wallet."
|
||||||
return nil, fmt.Errorf("not implemented")
|
}
|
||||||
|
return "WalletConnect v2 is not configured. Set WALLETCONNECT_PROJECT_ID to publish relay config; browser wallet auth remains available at /api/v1/auth/wallet."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Connect reports client-side WalletConnect posture. Pairing runs in the browser when projectId is published.
|
||||||
|
func (wc *WalletConnect) Connect(_ context.Context) (*ConnectResponse, error) {
|
||||||
|
if !wc.enabled() {
|
||||||
|
return &ConnectResponse{
|
||||||
|
Status: WalletConnectStatusDisabled,
|
||||||
|
Enabled: false,
|
||||||
|
FallbackAuth: "/api/v1/auth/wallet",
|
||||||
|
Message: wc.publicMessage(),
|
||||||
|
}, fmt.Errorf("walletconnect is disabled")
|
||||||
|
}
|
||||||
|
return &ConnectResponse{
|
||||||
|
Status: "client",
|
||||||
|
Enabled: true,
|
||||||
|
FallbackAuth: "/api/v1/auth/wallet",
|
||||||
|
Message: "Initialize WalletConnect in the browser via /wallet using the published projectId; authenticate with /api/v1/auth/wallet after pairing.",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSession returns a registered browser-paired WalletConnect session snapshot.
|
||||||
|
func (wc *WalletConnect) GetSession(_ context.Context, sessionID string) (*Session, error) {
|
||||||
|
if strings.TrimSpace(sessionID) == "" {
|
||||||
|
return nil, fmt.Errorf("session id is required")
|
||||||
|
}
|
||||||
|
if session, ok := lookupWalletConnectSession(sessionID); ok {
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
return &Session{
|
||||||
|
SessionID: sessionID,
|
||||||
|
Connected: false,
|
||||||
|
Status: WalletConnectStatusClient,
|
||||||
|
Message: "Session not registered yet. Pair on /wallet, then POST /api/v1/walletconnect/session with sessionId and address.",
|
||||||
|
}, fmt.Errorf("walletconnect session not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterSession stores a client-paired WalletConnect session for operator lookup.
|
||||||
|
func (wc *WalletConnect) RegisterSession(_ context.Context, sessionID, address string, chainID int) (*Session, error) {
|
||||||
|
if strings.TrimSpace(sessionID) == "" {
|
||||||
|
return nil, fmt.Errorf("session id is required")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(strings.ToLower(address), "0x") || len(address) != 42 {
|
||||||
|
return nil, fmt.Errorf("valid address is required")
|
||||||
|
}
|
||||||
|
if chainID <= 0 {
|
||||||
|
chainID = wc.chainID
|
||||||
|
}
|
||||||
|
return RegisterClientSession(sessionID, address, chainID), nil
|
||||||
|
}
|
||||||
|
|||||||
88
backend/wallet/walletconnect_sessions.go
Normal file
88
backend/wallet/walletconnect_sessions.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package wallet
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const walletConnectSessionTTL = 24 * time.Hour
|
||||||
|
|
||||||
|
type storedWalletConnectSession struct {
|
||||||
|
SessionID string
|
||||||
|
Address string
|
||||||
|
ChainID int
|
||||||
|
Connected bool
|
||||||
|
Status string
|
||||||
|
Message string
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
walletConnectSessionMu sync.RWMutex
|
||||||
|
walletConnectSessions = map[string]storedWalletConnectSession{}
|
||||||
|
)
|
||||||
|
|
||||||
|
func purgeExpiredWalletConnectSessions(now time.Time) {
|
||||||
|
for id, session := range walletConnectSessions {
|
||||||
|
if now.Sub(session.UpdatedAt) > walletConnectSessionTTL {
|
||||||
|
delete(walletConnectSessions, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterClientSession records a browser-paired WalletConnect session snapshot.
|
||||||
|
func RegisterClientSession(sessionID, address string, chainID int) *Session {
|
||||||
|
sessionID = strings.TrimSpace(sessionID)
|
||||||
|
address = strings.TrimSpace(address)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
walletConnectSessionMu.Lock()
|
||||||
|
defer walletConnectSessionMu.Unlock()
|
||||||
|
purgeExpiredWalletConnectSessions(now)
|
||||||
|
|
||||||
|
record := storedWalletConnectSession{
|
||||||
|
SessionID: sessionID,
|
||||||
|
Address: address,
|
||||||
|
ChainID: chainID,
|
||||||
|
Connected: true,
|
||||||
|
Status: WalletConnectStatusClient,
|
||||||
|
Message: "WalletConnect session registered by browser client after pairing.",
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
walletConnectSessions[sessionID] = record
|
||||||
|
return sessionFromStored(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
func lookupWalletConnectSession(sessionID string) (*Session, bool) {
|
||||||
|
sessionID = strings.TrimSpace(sessionID)
|
||||||
|
if sessionID == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
walletConnectSessionMu.Lock()
|
||||||
|
defer walletConnectSessionMu.Unlock()
|
||||||
|
purgeExpiredWalletConnectSessions(now)
|
||||||
|
|
||||||
|
record, ok := walletConnectSessions[sessionID]
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if now.Sub(record.UpdatedAt) > walletConnectSessionTTL {
|
||||||
|
delete(walletConnectSessions, sessionID)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return sessionFromStored(record), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func sessionFromStored(record storedWalletConnectSession) *Session {
|
||||||
|
return &Session{
|
||||||
|
SessionID: record.SessionID,
|
||||||
|
Address: record.Address,
|
||||||
|
ChainID: record.ChainID,
|
||||||
|
Connected: record.Connected,
|
||||||
|
Status: record.Status,
|
||||||
|
Message: record.Message,
|
||||||
|
}
|
||||||
|
}
|
||||||
25
backend/wallet/walletconnect_sessions_test.go
Normal file
25
backend/wallet/walletconnect_sessions_test.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package wallet
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRegisterAndLookupWalletConnectSession(t *testing.T) {
|
||||||
|
sessionID := "wc-test-topic-123"
|
||||||
|
address := "0x4A666F96fC8764181194447A7dFdb7d471b301C8"
|
||||||
|
|
||||||
|
registered := RegisterClientSession(sessionID, address, 138)
|
||||||
|
if registered == nil || !registered.Connected {
|
||||||
|
t.Fatalf("expected connected session, got %#v", registered)
|
||||||
|
}
|
||||||
|
|
||||||
|
wc := NewWalletConnect(138)
|
||||||
|
session, err := wc.GetSession(context.Background(), sessionID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSession: %v", err)
|
||||||
|
}
|
||||||
|
if session.Address != address {
|
||||||
|
t.Fatalf("expected address %s, got %s", address, session.Address)
|
||||||
|
}
|
||||||
|
}
|
||||||
1
cache/solidity-files-cache.json
vendored
1
cache/solidity-files-cache.json
vendored
@@ -1 +0,0 @@
|
|||||||
{"_format":"","paths":{"artifacts":"out","build_infos":"out/build-info","sources":"src","tests":"test","scripts":"script","libraries":["lib"]},"files":{"src/MockLinkToken.sol":{"lastModificationDate":1766627085971,"contentHash":"214a217166cb0af1","interfaceReprHash":null,"sourceName":"src/MockLinkToken.sol","imports":[],"versionRequirement":"^0.8.19","artifacts":{"MockLinkToken":{"0.8.24":{"default":{"path":"MockLinkToken.sol/MockLinkToken.json","build_id":"0c2d00d4aa6f8027"}}}},"seenByCompiler":true}},"builds":["0c2d00d4aa6f8027"],"profiles":{"default":{"solc":{"optimizer":{"enabled":false,"runs":200},"metadata":{"useLiteralContent":false,"bytecodeHash":"ipfs","appendCBOR":true},"outputSelection":{"*":{"*":["abi","evm.bytecode.object","evm.bytecode.sourceMap","evm.bytecode.linkReferences","evm.deployedBytecode.object","evm.deployedBytecode.sourceMap","evm.deployedBytecode.linkReferences","evm.deployedBytecode.immutableReferences","evm.methodIdentifiers","metadata"]}},"evmVersion":"prague","viaIR":false,"libraries":{}},"vyper":{"evmVersion":"prague","outputSelection":{"*":{"*":["abi","evm.bytecode","evm.deployedBytecode"]}}}}},"preprocessed":false,"mocks":[]}
|
|
||||||
61
config/explorer-bridge-lanes.v1.json
Normal file
61
config/explorer-bridge-lanes.v1.json
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"updated": "2026-05-23",
|
||||||
|
"min_link_wei": "1000000000000000000",
|
||||||
|
"lanes": [
|
||||||
|
{
|
||||||
|
"key": "chain138",
|
||||||
|
"chain_name": "Defi Oracle Meta Mainnet (138)",
|
||||||
|
"chain_id": 138,
|
||||||
|
"config_ready": true,
|
||||||
|
"rpc_envs": ["RPC_URL", "RPC_URL_138"],
|
||||||
|
"rpc_default": "http://192.168.11.211:8545",
|
||||||
|
"link_token": "0xb7721dd53a8c629d9f1ba31a5819afe250002b03",
|
||||||
|
"weth9_bridge": "0xcacfd227A040002e49e2e01626363071324f820a",
|
||||||
|
"weth10_bridge": "0xe0E93247376aa097dB308B92e6Ba36bA015535D0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "gnosis",
|
||||||
|
"chain_name": "Gnosis (100)",
|
||||||
|
"chain_id": 100,
|
||||||
|
"config_ready": true,
|
||||||
|
"rpc_envs": ["GNOSIS_RPC", "GNOSIS_MAINNET_RPC", "GNOSIS_RPC_URL"],
|
||||||
|
"rpc_default": "https://rpc.gnosischain.com",
|
||||||
|
"link_token": "0xE2e73A1c69ecF83F464EFCE6A5be353a37cA09b2",
|
||||||
|
"weth9_bridge": "0xc8656F24488cb90c452058da92d1a25BA464eaAE",
|
||||||
|
"weth10_bridge": "0xa846aeAD3071df1b6439d5D813156aCE7C2c1DA1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "cronos",
|
||||||
|
"chain_name": "Cronos (25)",
|
||||||
|
"chain_id": 25,
|
||||||
|
"config_ready": true,
|
||||||
|
"rpc_envs": ["CRONOS_RPC", "CRONOS_RPC_URL", "CRONOS_MAINNET_RPC"],
|
||||||
|
"rpc_default": "https://evm.cronos.org",
|
||||||
|
"link_token": "0x8c80A01F461f297Df7F9DA3A4f740D7297C8Ac85",
|
||||||
|
"weth9_bridge": "0x3Cc23d086fCcbAe1e5f3FE2bA4A263E1D27d8Cab",
|
||||||
|
"weth10_bridge": "0x105F8A15b819948a89153505762444Ee9f324684"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "celo",
|
||||||
|
"chain_name": "Celo (42220)",
|
||||||
|
"chain_id": 42220,
|
||||||
|
"config_ready": true,
|
||||||
|
"rpc_envs": ["CELO_RPC", "CELO_MAINNET_RPC"],
|
||||||
|
"rpc_default": "https://forno.celo.org",
|
||||||
|
"link_token": "0xd07294e6E917e07dfDcee882dd1e2565085C2ae0",
|
||||||
|
"weth9_bridge": "0xAb57BF30F1354CA0590af22D8974c7f24DB2DbD7",
|
||||||
|
"weth10_bridge": "0xa780ef19A041745d353c9432f2a7f5A241335ffE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "wemix",
|
||||||
|
"chain_name": "Wemix (1111)",
|
||||||
|
"chain_id": 1111,
|
||||||
|
"config_ready": true,
|
||||||
|
"rpc_envs": ["WEMIX_RPC", "WEMIX_MAINNET_RPC"],
|
||||||
|
"rpc_default": "https://api.wemix.com",
|
||||||
|
"link_token": "0x80f1FcdC96B55e459BF52b998aBBE2c364935d69",
|
||||||
|
"weth9_bridge": "0xD3AD6831aacB5386B8A25BB8D8176a6C8a026f04",
|
||||||
|
"weth10_bridge": "0xa4B9DD039565AeD9641D45b57061f99d9cA6Df08"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
21
config/explorer-bridge-proof-transfers.example.json
Normal file
21
config/explorer-bridge-proof-transfers.example.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"updated": "2026-05-23T13:07:00Z",
|
||||||
|
"sprint": "tier-a-week-3",
|
||||||
|
"note": "Example shape for MISSION_CONTROL_PROOF_TRANSFERS_JSON. Operator live file: reports/status/bridge-lane-proof-transfers-latest.json (gitignored). Populate with scripts/bridge/run-lane-proof-transfers.sh --execute.",
|
||||||
|
"lanes": {
|
||||||
|
"chain138": [
|
||||||
|
{
|
||||||
|
"tx_hash": "0x0000000000000000000000000000000000000000000000000000000000000000",
|
||||||
|
"amount_eth": "0.001",
|
||||||
|
"dest_chain": "mainnet",
|
||||||
|
"dest_selector": "5009297550715157269",
|
||||||
|
"bridge": "0xcacfd227A040002e49e2e01626363071324f820a",
|
||||||
|
"recorded_at": "2026-05-23T00:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"gnosis": [],
|
||||||
|
"cronos": [],
|
||||||
|
"celo": [],
|
||||||
|
"wemix": []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -479,7 +479,7 @@ EOF
|
|||||||
```bash
|
```bash
|
||||||
cat > /etc/systemd/system/solacescanscout-frontend.service << 'EOF'
|
cat > /etc/systemd/system/solacescanscout-frontend.service << 'EOF'
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=SolaceScan Next Frontend Service
|
Description=DBIS Explorer Next Frontend Service
|
||||||
After=network.target explorer-api.service
|
After=network.target explorer-api.service
|
||||||
Requires=explorer-api.service
|
Requires=explorer-api.service
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Live Deployment Map
|
# Live Deployment Map
|
||||||
|
|
||||||
Current production deployment map for the SolaceScan public explorer surface.
|
Current production deployment map for the DBIS Explorer public explorer surface.
|
||||||
|
|
||||||
This file is the authoritative reference for the live explorer stack as of `2026-04-05`. It supersedes the older monolithic deployment notes in this directory when the question is "what is running in production right now?"
|
This file is the authoritative reference for the live explorer stack as of `2026-04-05`. It supersedes the older monolithic deployment notes in this directory when the question is "what is running in production right now?"
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
- 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)
|
- 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)
|
- 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)
|
- 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)
|
- 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)
|
- Full public smoke: [`check-explorer-e2e.sh`](../../scripts/verify/check-explorer-e2e.sh)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Next.js frontend proxy locations for SolaceScan.
|
# Next.js frontend proxy locations for DBIS Explorer.
|
||||||
# Keep the existing higher-priority locations for:
|
# Keep the existing higher-priority locations for:
|
||||||
# - /api/
|
# - /api/
|
||||||
# - /api/config/token-list
|
# - /api/config/token-list
|
||||||
@@ -12,6 +12,12 @@
|
|||||||
# Include these locations after those API/static locations and before any legacy
|
# Include these locations after those API/static locations and before any legacy
|
||||||
# catch-all that serves /var/www/html/index.html directly.
|
# catch-all that serves /var/www/html/index.html directly.
|
||||||
|
|
||||||
|
location ^~ /legacy/ {
|
||||||
|
alias /var/www/html/legacy/;
|
||||||
|
try_files $uri $uri/ /legacy/index.html;
|
||||||
|
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||||
|
}
|
||||||
|
|
||||||
location ^~ /_next/ {
|
location ^~ /_next/ {
|
||||||
proxy_pass http://127.0.0.1:3000;
|
proxy_pass http://127.0.0.1:3000;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=SolaceScan Next Frontend Service
|
Description=DBIS Explorer Next Frontend Service
|
||||||
After=network.target
|
After=network.target
|
||||||
Wants=network.target
|
Wants=network.target
|
||||||
|
|
||||||
|
|||||||
146
docs/API.md
Normal file
146
docs/API.md
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
# API Reference
|
||||||
|
|
||||||
|
Canonical, machine-readable spec: [`backend/api/rest/swagger.yaml`](../backend/api/rest/swagger.yaml)
|
||||||
|
(OpenAPI 3.0.3). This document is a human index regenerated from that
|
||||||
|
file — run `scripts/gen-api-md.py` after editing `swagger.yaml` to
|
||||||
|
refresh it.
|
||||||
|
|
||||||
|
## Base URLs
|
||||||
|
|
||||||
|
| Env | URL |
|
||||||
|
|-----|-----|
|
||||||
|
| Production | `https://api.d-bis.org` |
|
||||||
|
| Local dev | `http://localhost:8080` |
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Track 1 endpoints (listed below under **Track1**, **Health**, and most
|
||||||
|
of **Blocks** / **Transactions** / **Search**) are public. Every other
|
||||||
|
endpoint requires a wallet JWT.
|
||||||
|
|
||||||
|
The flow:
|
||||||
|
|
||||||
|
1. `POST /api/v1/auth/nonce` with `{address}` → `{nonce}`
|
||||||
|
2. Sign the nonce with the wallet.
|
||||||
|
3. `POST /api/v1/auth/wallet` with `{address, nonce, signature}`
|
||||||
|
→ `{token, expiresAt, track, permissions}`
|
||||||
|
4. Send subsequent requests with `Authorization: Bearer <token>`.
|
||||||
|
5. Refresh before `expiresAt` with
|
||||||
|
`POST /api/v1/auth/refresh` (see [ARCHITECTURE.md](ARCHITECTURE.md)).
|
||||||
|
6. Log out with `POST /api/v1/auth/logout` — revokes the token's
|
||||||
|
`jti` server-side.
|
||||||
|
|
||||||
|
Per-track TTLs:
|
||||||
|
|
||||||
|
| Track | TTL |
|
||||||
|
|-------|-----|
|
||||||
|
| 1 | 12h |
|
||||||
|
| 2 | 8h |
|
||||||
|
| 3 | 4h |
|
||||||
|
| 4 | 60m |
|
||||||
|
|
||||||
|
## Rate limits
|
||||||
|
|
||||||
|
| Scope | Limit |
|
||||||
|
|-------|-------|
|
||||||
|
| Track 1 (per IP) | 100 req/min |
|
||||||
|
| Tracks 2–4 | Per-user, per-subscription; see subscription detail in `GET /api/v1/access/subscriptions` |
|
||||||
|
|
||||||
|
## Endpoint index
|
||||||
|
|
||||||
|
## Health
|
||||||
|
Health check endpoints
|
||||||
|
- `GET /health` — Health check
|
||||||
|
|
||||||
|
## Auth
|
||||||
|
Wallet and user-session authentication endpoints
|
||||||
|
- `POST /api/v1/auth/nonce` — Generate wallet auth nonce
|
||||||
|
- `POST /api/v1/auth/wallet` — Authenticate with wallet signature
|
||||||
|
- `POST /api/v1/auth/refresh` — Rotate a still-valid JWT (adds a new `jti`; revokes the old one) — PR #8
|
||||||
|
- `POST /api/v1/auth/logout` — Revoke the current JWT by `jti` — PR #8
|
||||||
|
- `POST /api/v1/auth/register` — Register an explorer access user
|
||||||
|
- `POST /api/v1/auth/login` — Log in to the explorer access console
|
||||||
|
|
||||||
|
## Access
|
||||||
|
RPC product catalog, subscriptions, and API key lifecycle
|
||||||
|
- `GET /api/v1/access/me` — Get current access-console user
|
||||||
|
- `GET /api/v1/access/products` — List available RPC access products (backed by `backend/config/rpc_products.yaml`, PR #7)
|
||||||
|
- `GET /api/v1/access/subscriptions` — List subscriptions for the signed-in user
|
||||||
|
- `GET /api/v1/access/admin/subscriptions` — List subscriptions for admin review
|
||||||
|
- `POST /api/v1/access/admin/subscriptions` — Request or activate product access
|
||||||
|
- `GET /api/v1/access/api-keys` — List API keys for the signed-in user
|
||||||
|
- `POST /api/v1/access/api-keys` — Create an API key
|
||||||
|
- `POST /api/v1/access/api-keys/{id}` — Revoke an API key
|
||||||
|
- `DELETE /api/v1/access/api-keys/{id}` — Revoke an API key
|
||||||
|
- `GET /api/v1/access/usage` — Get usage summary for the signed-in user
|
||||||
|
- `GET /api/v1/access/audit` — Get recent API activity for the signed-in user
|
||||||
|
- `GET /api/v1/access/admin/audit` — Get recent API activity across users for admin review
|
||||||
|
- `GET /api/v1/access/internal/validate-key` — Validate an API key for nginx auth_request or similar edge subrequests
|
||||||
|
- `POST /api/v1/access/internal/validate-key` — Validate an API key for internal edge enforcement
|
||||||
|
|
||||||
|
## Blocks
|
||||||
|
Block-related endpoints
|
||||||
|
- `GET /api/v1/blocks` — List blocks
|
||||||
|
- `GET /api/v1/blocks/{chain_id}/{number}` — Get block by number
|
||||||
|
|
||||||
|
## Transactions
|
||||||
|
Transaction-related endpoints
|
||||||
|
- `GET /api/v1/transactions` — List transactions
|
||||||
|
|
||||||
|
## Search
|
||||||
|
Unified search endpoints
|
||||||
|
- `GET /api/v1/search` — Unified search
|
||||||
|
|
||||||
|
## Track1
|
||||||
|
Public RPC gateway endpoints (no auth required)
|
||||||
|
- `GET /api/v1/track1/blocks/latest` — Get latest blocks (Public)
|
||||||
|
|
||||||
|
## MissionControl
|
||||||
|
Public mission-control health, bridge trace, and cached liquidity helpers
|
||||||
|
- `GET /api/v1/mission-control/stream` — Mission-control SSE stream
|
||||||
|
- `GET /api/v1/mission-control/liquidity/token/{address}/pools` — Cached liquidity proxy
|
||||||
|
- `GET /api/v1/mission-control/bridge/trace` — Resolve a transaction through Blockscout and label 138-side contracts
|
||||||
|
|
||||||
|
## Track2
|
||||||
|
Indexed explorer endpoints (auth required)
|
||||||
|
- `GET /api/v1/track2/search` — Advanced search (Auth Required)
|
||||||
|
|
||||||
|
## Track4
|
||||||
|
Operator endpoints (Track 4 + IP whitelist)
|
||||||
|
- `POST /api/v1/track4/operator/run-script` — Run an allowlisted operator script
|
||||||
|
|
||||||
|
## Error shape
|
||||||
|
|
||||||
|
All errors use:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "short_code",
|
||||||
|
"message": "human-readable explanation"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Common codes:
|
||||||
|
|
||||||
|
| HTTP | `error` | Meaning |
|
||||||
|
|------|---------|---------|
|
||||||
|
| 400 | `bad_request` | Malformed body or missing param |
|
||||||
|
| 401 | `unauthorized` | Missing or invalid JWT |
|
||||||
|
| 401 | `token_revoked` | JWT's `jti` is in `jwt_revocations` (PR #8) |
|
||||||
|
| 403 | `forbidden` | Authenticated but below required track |
|
||||||
|
| 404 | `not_found` | Record does not exist |
|
||||||
|
| 405 | `method_not_allowed` | HTTP method not supported for route |
|
||||||
|
| 429 | `rate_limited` | Over the track's per-window quota |
|
||||||
|
| 503 | `service_unavailable` | Backend dep unavailable or migration missing |
|
||||||
|
|
||||||
|
## Generating client SDKs
|
||||||
|
|
||||||
|
The `swagger.yaml` file is standard OpenAPI 3.0.3; any generator works.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# TypeScript fetch client
|
||||||
|
npx openapi-typescript backend/api/rest/swagger.yaml -o frontend/src/api/types.ts
|
||||||
|
|
||||||
|
# Go client
|
||||||
|
oapi-codegen -package explorerclient backend/api/rest/swagger.yaml > client.go
|
||||||
|
```
|
||||||
162
docs/ARCHITECTURE.md
Normal file
162
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
SolaceScan is a four-tier block explorer + access-control plane for
|
||||||
|
Chain 138. Every request is classified into one of four **tracks**;
|
||||||
|
higher tracks require stronger authentication and hit different
|
||||||
|
internal subsystems.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
U[User / wallet / operator] -->|HTTPS| FE[Next.js frontend<br/>:3000]
|
||||||
|
U -->|direct API<br/>or SDK| EDGE[Edge / nginx<br/>:443]
|
||||||
|
FE --> EDGE
|
||||||
|
EDGE --> API[Go REST API<br/>backend/api/rest :8080]
|
||||||
|
|
||||||
|
API --> PG[(Postgres +<br/>TimescaleDB)]
|
||||||
|
API --> ES[(Elasticsearch)]
|
||||||
|
API --> RD[(Redis)]
|
||||||
|
API --> RPC[(Chain 138 RPC<br/>core / alltra / thirdweb)]
|
||||||
|
|
||||||
|
IDX[Indexer<br/>backend/indexer] --> PG
|
||||||
|
IDX --> ES
|
||||||
|
RPC --> IDX
|
||||||
|
|
||||||
|
subgraph Access layer
|
||||||
|
EDGE -->|auth_request| VK[validate-key<br/>/api/v1/access/internal/validate-key]
|
||||||
|
VK --> API
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tracks
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph Track1[Track 1 — public, no auth]
|
||||||
|
T1A[/blocks]
|
||||||
|
T1B[/transactions]
|
||||||
|
T1C[/search]
|
||||||
|
T1D[/api/v1/track1/*]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Track2[Track 2 — wallet-verified]
|
||||||
|
T2A[Subscriptions]
|
||||||
|
T2B[API key lifecycle]
|
||||||
|
T2C[Usage + audit self-view]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Track3[Track 3 — analytics]
|
||||||
|
T3A[Advanced analytics]
|
||||||
|
T3B[Admin audit]
|
||||||
|
T3C[Admin subscription review]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Track4[Track 4 — operator]
|
||||||
|
T4A[/api/v1/track4/operator/run-script]
|
||||||
|
T4B[Mission-control SSE]
|
||||||
|
T4C[Ops tooling]
|
||||||
|
end
|
||||||
|
|
||||||
|
Track1 --> Track2 --> Track3 --> Track4
|
||||||
|
```
|
||||||
|
|
||||||
|
Authentication for tracks 2–4 is SIWE-style: client hits
|
||||||
|
`/api/v1/auth/nonce`, signs the nonce with its wallet, posts the
|
||||||
|
signature to `/api/v1/auth/wallet`, gets a JWT back. JWTs carry the
|
||||||
|
resolved `track` claim and a `jti` for server-side revocation (see
|
||||||
|
`backend/auth/wallet_auth.go`).
|
||||||
|
|
||||||
|
### Per-track token TTLs
|
||||||
|
|
||||||
|
| Track | TTL | Rationale |
|
||||||
|
|------|-----|-----------|
|
||||||
|
| 1 | 12h | Public / long-lived session OK |
|
||||||
|
| 2 | 8h | Business day |
|
||||||
|
| 3 | 4h | Analytics session |
|
||||||
|
| 4 | **60 min** | Operator tokens are the most dangerous; short TTL + `POST /api/v1/auth/refresh` |
|
||||||
|
|
||||||
|
Revocation lives in `jwt_revocations` (migration `0016`). Logging out
|
||||||
|
(`POST /api/v1/auth/logout`) inserts the token's `jti` so subsequent
|
||||||
|
validation rejects it.
|
||||||
|
|
||||||
|
## Sign-in flow (wallet)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
autonumber
|
||||||
|
actor W as Wallet
|
||||||
|
participant FE as Frontend
|
||||||
|
participant API as REST API
|
||||||
|
participant DB as Postgres
|
||||||
|
|
||||||
|
W->>FE: connect / sign-in
|
||||||
|
FE->>API: POST /api/v1/auth/nonce {address}
|
||||||
|
API->>DB: insert wallet_nonces(address, nonce, expires_at)
|
||||||
|
API-->>FE: {nonce}
|
||||||
|
FE->>W: signTypedData/personal_sign(nonce)
|
||||||
|
W-->>FE: signature
|
||||||
|
FE->>API: POST /api/v1/auth/wallet {address, nonce, signature}
|
||||||
|
API->>API: ecrecover → verify address
|
||||||
|
API->>DB: consume nonce; resolve user track
|
||||||
|
API-->>FE: {token, expiresAt, track, permissions}
|
||||||
|
FE-->>W: session active
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data flow (indexer ↔ API)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
RPC[(Chain 138 RPC)] -->|new blocks| IDX[Indexer]
|
||||||
|
IDX -->|INSERT blocks, txs, logs| PG[(Postgres)]
|
||||||
|
IDX -->|bulk index| ES[(Elasticsearch)]
|
||||||
|
IDX -->|invalidate| RD[(Redis)]
|
||||||
|
|
||||||
|
API[REST API] -->|SELECT| PG
|
||||||
|
API -->|search, facets| ES
|
||||||
|
API -->|cached RPC proxy| RD
|
||||||
|
API -->|passthrough for deep reads| RPC
|
||||||
|
```
|
||||||
|
|
||||||
|
## Subsystems
|
||||||
|
|
||||||
|
- **`backend/api/rest`** — HTTP API. One package; every handler lives
|
||||||
|
under `backend/api/rest/*.go`. AI endpoints were split into
|
||||||
|
`ai.go` + `ai_context.go` + `ai_routes.go` + `ai_docs.go` +
|
||||||
|
`ai_xai.go` + `ai_helpers.go` by PR #6 to keep file size
|
||||||
|
manageable.
|
||||||
|
- **`backend/auth`** — wallet auth (nonce issue, signature verify,
|
||||||
|
JWT issuance / validation / revocation / refresh).
|
||||||
|
- **`backend/indexer`** — Chain 138 block/tx/log indexer, writes
|
||||||
|
Postgres + Elasticsearch, invalidates Redis.
|
||||||
|
- **`backend/analytics`** — longer-running queries: token distribution,
|
||||||
|
holder concentration, liquidity-pool aggregates.
|
||||||
|
- **`backend/api/track4`** — operator-scoped endpoints
|
||||||
|
(`run-script`, mission-control).
|
||||||
|
- **`frontend`** — Next.js 14 pages-router app. Router decision
|
||||||
|
(PR #9) is final: no `src/app/`.
|
||||||
|
|
||||||
|
## Runtime dependencies
|
||||||
|
|
||||||
|
| Service | Why |
|
||||||
|
|---------|-----|
|
||||||
|
| Postgres (+ TimescaleDB) | Chain data, users, subscriptions, `jwt_revocations` |
|
||||||
|
| Elasticsearch | Full-text search, facets |
|
||||||
|
| Redis | Response cache, rate-limit counters, SSE fan-out |
|
||||||
|
| Chain 138 RPC | Upstream source of truth; three lanes — core / alltra / thirdweb — catalogued in `backend/config/rpc_products.yaml` |
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
See [deployment/README.md](../deployment/README.md) for compose and
|
||||||
|
production deploy details. The `deployment/docker-compose.yml` file
|
||||||
|
is the reference local stack and is what `make e2e-full` drives.
|
||||||
|
|
||||||
|
## Security posture
|
||||||
|
|
||||||
|
- `JWT_SECRET` and `CSP_HEADER` are **fail-fast** — a production
|
||||||
|
binary refuses to start without them (PR #3).
|
||||||
|
- Secrets never live in-repo; `.gitleaks.toml` blocks known-bad
|
||||||
|
patterns at commit time.
|
||||||
|
- Rotation checklist: [docs/SECURITY.md](SECURITY.md).
|
||||||
|
- Track-4 token TTL capped at 60 min; every issued token is
|
||||||
|
revocable by `jti`.
|
||||||
@@ -224,7 +224,7 @@ User → chainlist.org → Search "DBIS" → Click "Add to MetaMask"
|
|||||||
|
|
||||||
```
|
```
|
||||||
User → MetaMask → Click "View on Explorer"
|
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 displays transaction details
|
||||||
→ Blockscout API provides the data
|
→ Blockscout API provides the data
|
||||||
```
|
```
|
||||||
@@ -285,4 +285,3 @@ User → MetaMask → View Token Balance
|
|||||||
|
|
||||||
**Last Updated**: 2025-12-24
|
**Last Updated**: 2025-12-24
|
||||||
**Status**: Analysis Complete
|
**Status**: Analysis Complete
|
||||||
|
|
||||||
|
|||||||
@@ -53,9 +53,11 @@ directly instead of relying on the older static script env contract below.
|
|||||||
|
|
||||||
Historical static-script environment variables:
|
Historical static-script environment variables:
|
||||||
|
|
||||||
- `IP`: Production server IP (default: 192.168.11.140)
|
- `IP`: Production server IP (required; no default)
|
||||||
- `DOMAIN`: Domain name (default: explorer.d-bis.org)
|
- `DOMAIN`: Domain name (required; no default)
|
||||||
- `PASSWORD`: SSH password (default: ***REDACTED-LEGACY-PW***)
|
- `SSH_PASSWORD`: SSH password (required; no default; previous
|
||||||
|
hardcoded default has been removed — see
|
||||||
|
[SECURITY.md](SECURITY.md))
|
||||||
|
|
||||||
These applied to the deprecated static deploy script and are no longer the
|
These applied to the deprecated static deploy script and are no longer the
|
||||||
recommended operator interface.
|
recommended operator interface.
|
||||||
|
|||||||
28
docs/EXPLORER_PUBLIC_API_ACCESS.md
Normal file
28
docs/EXPLORER_PUBLIC_API_ACCESS.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Explorer public API access (decision record)
|
||||||
|
|
||||||
|
**Date:** 2026-05-23
|
||||||
|
**Live page:** `/docs/public-api-access`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Surface | Auth today | Notes |
|
||||||
|
|---------|------------|-------|
|
||||||
|
| Blockscout read API (`/api/v2/*`) | None | Same-origin proxy to Blockscout |
|
||||||
|
| Public JSON (stats, bridge routes, token lists, etc.) | None | Listed in footer **Public APIs** |
|
||||||
|
| Managed RPC keys | Wallet session on `/access` | `POST /api/v1/access/api-keys` after `/api/v1/auth/wallet` |
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
1. **Keep Blockscout and public JSON unauthenticated** for integrators on the public explorer domain.
|
||||||
|
2. **Managed RPC keys** remain the wallet-authenticated product on `/access` — not a Blockscout API-key layer.
|
||||||
|
3. **Future path (Option B):** nginx/API-gateway throttling with optional `X-API-Key` for higher quotas if abuse appears. Full external developer portal remains optional.
|
||||||
|
|
||||||
|
## Integrator flow
|
||||||
|
|
||||||
|
- Read-only: use footer links or `/docs/public-api-access` endpoint list.
|
||||||
|
- Higher limits / RPC: connect wallet on `/wallet`, open `/access`, create scoped keys (tier, product, expiry, quota).
|
||||||
|
|
||||||
|
## Operator
|
||||||
|
|
||||||
|
- No nginx key gate required until rate-limit policy changes.
|
||||||
|
- Support contact: `support@d-bis.org`
|
||||||
127
docs/SECURITY.md
Normal file
127
docs/SECURITY.md
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
# Security policy and rotation checklist
|
||||||
|
|
||||||
|
This document describes how secrets flow through the SolaceScan explorer and
|
||||||
|
the operator steps required to rotate credentials that were previously
|
||||||
|
checked into this repository.
|
||||||
|
|
||||||
|
## Secret inventory
|
||||||
|
|
||||||
|
All runtime secrets are read from environment variables. Nothing sensitive
|
||||||
|
is committed to the repo.
|
||||||
|
|
||||||
|
| Variable | Used by | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `JWT_SECRET` | `backend/api/rest/server.go` | HS256 signing key. Must be ≥32 bytes. Required when `APP_ENV=production` or `GO_ENV=production`. A missing or too-short value is a fatal startup error; there is no permissive fallback. |
|
||||||
|
| `CSP_HEADER` | `backend/api/rest/server.go` | Full Content-Security-Policy string. Required in production. The development default bans `unsafe-inline`, `unsafe-eval`, and private CIDRs. |
|
||||||
|
| `DB_PASSWORD` | deployment scripts (`EXECUTE_DEPLOYMENT.sh`, `EXECUTE_NOW.sh`) and the API | Postgres password for the `explorer` role. |
|
||||||
|
| `SSH_PASSWORD` | `scripts/analyze-besu-logs.sh`, `scripts/check-besu-config.sh`, `scripts/check-besu-logs-with-password.sh`, `scripts/check-failed-transaction-details.sh`, `scripts/enable-besu-debug-api.sh` | SSH password used to reach the Besu VMs. Scripts fail fast if unset. |
|
||||||
|
| `NEW_PASSWORD` | `scripts/set-vmid-password.sh`, `scripts/set-vmid-password-correct.sh` | Password being set on a Proxmox VM. Fail-fast required. |
|
||||||
|
| `CORS_ALLOWED_ORIGIN` | `backend/api/rest/server.go` | Optional. When set, restricts `Access-Control-Allow-Origin`. Defaults to `*` — do not rely on that in production. |
|
||||||
|
| `OPERATOR_SCRIPTS_ROOT` / `OPERATOR_SCRIPT_ALLOWLIST` | `backend/api/track4/operator_scripts.go` | Required to enable the Track-4 run-script endpoint. |
|
||||||
|
| `OPERATOR_SCRIPT_TIMEOUT_SEC` | as above | Optional cap (1–599 seconds). |
|
||||||
|
|
||||||
|
## Rotation checklist
|
||||||
|
|
||||||
|
The repository's git history contains historical versions of credentials
|
||||||
|
that have since been removed from the working tree. Treat those credentials
|
||||||
|
as compromised. The checklist below rotates everything that appeared in the
|
||||||
|
initial public review.
|
||||||
|
|
||||||
|
> **This repository does not rotate credentials on its own. The checklist
|
||||||
|
> below is the operator's responsibility.** Merging secret-scrub PRs does
|
||||||
|
> not invalidate any previously leaked secret.
|
||||||
|
|
||||||
|
1. **Rotate the Postgres `explorer` role password.**
|
||||||
|
- Generate a new random password (`openssl rand -base64 24`).
|
||||||
|
- `ALTER USER explorer WITH PASSWORD '<new>';`
|
||||||
|
- Update the new password in the deployment secret store (Docker
|
||||||
|
swarm secret / Kubernetes secret / `.env.secrets` on the host).
|
||||||
|
- Restart the API and indexer services so they pick up the new value.
|
||||||
|
|
||||||
|
2. **Rotate the Proxmox / Besu VM SSH password.**
|
||||||
|
- `sudo passwd besu` (or equivalent) on each affected VM.
|
||||||
|
- Or, preferred: disable password auth entirely and move to SSH keys
|
||||||
|
(`PasswordAuthentication no` in `/etc/ssh/sshd_config`).
|
||||||
|
|
||||||
|
3. **Rotate `JWT_SECRET`.**
|
||||||
|
- Generate 32+ bytes (`openssl rand -base64 48`).
|
||||||
|
- Deploy the new value to every API replica simultaneously.
|
||||||
|
- Note: rotating invalidates every outstanding wallet auth token. Plan
|
||||||
|
for a short window where users will need to re-sign.
|
||||||
|
- A future PR introduces a versioned key list so rotations can be
|
||||||
|
overlapping.
|
||||||
|
|
||||||
|
4. **Rotate any API keys (e.g. xAI / OpenSea) referenced by
|
||||||
|
`backend/api/rest/ai.go` and the frontend.** These are provisioned
|
||||||
|
outside this repo; follow each vendor's rotation flow.
|
||||||
|
|
||||||
|
5. **Audit git history.**
|
||||||
|
- Run `gitleaks detect --source . --redact` at HEAD.
|
||||||
|
- Run `gitleaks detect --log-opts="--all"` over the full history.
|
||||||
|
- Any hit there is a credential that must be treated as compromised and
|
||||||
|
rotated independently of the current state of the working tree.
|
||||||
|
- 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.
|
||||||
|
- `govulncheck`, `staticcheck`, and `go vet -vet=all` on the backend.
|
||||||
|
- `eslint` and `tsc --noEmit` on the frontend.
|
||||||
|
|
||||||
|
## Reporting a vulnerability
|
||||||
|
|
||||||
|
Do not open public issues for security reports. Email the maintainers
|
||||||
|
listed in `CONTRIBUTING.md`.
|
||||||
86
docs/TESTING.md
Normal file
86
docs/TESTING.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# Testing
|
||||||
|
|
||||||
|
The explorer has four test tiers. Run them in order of fidelity when
|
||||||
|
debugging a regression.
|
||||||
|
|
||||||
|
## 1. Unit / package tests
|
||||||
|
|
||||||
|
Fast. Run on every PR.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
cd backend && go test ./...
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
cd frontend && npm test # lint + type-check
|
||||||
|
cd frontend && npm run test:unit # vitest
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Static analysis
|
||||||
|
|
||||||
|
Blocking on CI since PR #5 (`chore(ci): align Go to 1.23.x, add
|
||||||
|
staticcheck/govulncheck/gitleaks gates`).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && staticcheck ./...
|
||||||
|
cd backend && govulncheck ./...
|
||||||
|
git diff master... | gitleaks protect --staged --config ../.gitleaks.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Production-targeting Playwright
|
||||||
|
|
||||||
|
Runs against `https://explorer.d-bis.org` (or the URL in `EXPLORER_URL`)
|
||||||
|
and only checks public routes. Useful as a production canary; wired
|
||||||
|
into the `test-e2e` Make target.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
EXPLORER_URL=https://explorer.d-bis.org make test-e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Full-stack Playwright (`make e2e-full`)
|
||||||
|
|
||||||
|
Spins up the entire stack locally — `postgres`, `elasticsearch`,
|
||||||
|
`redis` via docker-compose, plus a local build of `backend/api/rest`
|
||||||
|
and `frontend` — then runs the full-stack Playwright spec against it.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make e2e-full
|
||||||
|
```
|
||||||
|
|
||||||
|
What it does, in order:
|
||||||
|
|
||||||
|
1. `docker compose -p explorer-e2e up -d postgres elasticsearch redis`
|
||||||
|
2. Wait for Postgres readiness.
|
||||||
|
3. Run `go run database/migrations/migrate.go` to apply schema +
|
||||||
|
seeds (including `0016_jwt_revocations` from PR #8).
|
||||||
|
4. `go run ./backend/api/rest` on port `8080`.
|
||||||
|
5. `npm ci && npm run build && npm run start` on port `3000`.
|
||||||
|
6. `npx playwright test scripts/e2e-full-stack.spec.ts`.
|
||||||
|
7. Tear everything down (unless `E2E_KEEP_STACK=1`).
|
||||||
|
|
||||||
|
Screenshots of every route are written to
|
||||||
|
`test-results/screenshots/<route>.png`.
|
||||||
|
|
||||||
|
### Env vars
|
||||||
|
|
||||||
|
| Var | Default | Purpose |
|
||||||
|
|-----|---------|---------|
|
||||||
|
| `EXPLORER_URL` | `http://localhost:3000` | Frontend base URL for the spec |
|
||||||
|
| `EXPLORER_API_URL` | `http://localhost:8080` | Backend base URL |
|
||||||
|
| `JWT_SECRET` | generated per-run | Required by backend fail-fast check (PR #3) |
|
||||||
|
| `CSP_HEADER` | dev-safe default | Same |
|
||||||
|
| `E2E_KEEP_STACK` | `0` | If `1`, leave the stack up after the run |
|
||||||
|
| `E2E_SKIP_DOCKER` | `0` | If `1`, assume docker services already running |
|
||||||
|
| `E2E_SCREENSHOT_DIR` | `test-results/screenshots` | Where to write PNGs |
|
||||||
|
|
||||||
|
### CI integration
|
||||||
|
|
||||||
|
`.github/workflows/e2e-full.yml` runs `make e2e-full` on:
|
||||||
|
|
||||||
|
* **Manual** trigger (`workflow_dispatch`).
|
||||||
|
* **PRs labelled `run-e2e-full`** — apply the label when a change
|
||||||
|
warrants full-stack validation (migrations, auth, routing changes).
|
||||||
|
* **Nightly** at 04:00 UTC.
|
||||||
|
|
||||||
|
Screenshots and the Playwright HTML report are uploaded as build
|
||||||
|
artefacts.
|
||||||
19
docs/TOKEN_LIST_SURFACES.md
Normal file
19
docs/TOKEN_LIST_SURFACES.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Token list surfaces
|
||||||
|
|
||||||
|
The explorer uses two public token-list endpoints. Application code should pick the list through `getTokenListForSurface()` / `tokensApi.listForSurface()` rather than hard-coding `/api/config/token-list`.
|
||||||
|
|
||||||
|
| Surface | Endpoint | Use when |
|
||||||
|
|---------|----------|----------|
|
||||||
|
| `wallet` | `/api/v1/report/token-list?chainId=138` (fallback: config) | Wallet SSR, MetaMask watch list, featured-token dedup inputs |
|
||||||
|
| `catalog` | report (fallback: config) | `/tokens`, search token inference, homepage price feed curation |
|
||||||
|
| `extended` | `/api/config/token-list` | Full Metamask dual-chain catalog, provenance lookup merge, operations/liquidity/system/pools inventory |
|
||||||
|
|
||||||
|
Report list is the canonical Chain 138 trading set (31 tokens live). Config list is the extended catalog (190+ entries across chains).
|
||||||
|
|
||||||
|
## Page mapping
|
||||||
|
|
||||||
|
| Page / surface | Surface | Notes |
|
||||||
|
|----------------|---------|-------|
|
||||||
|
| `/wallet` | `wallet` | SSR + MetaMask watch list |
|
||||||
|
| `/tokens`, `/search`, homepage price feed | `catalog` | Canonical trading set with config fallback |
|
||||||
|
| `/liquidity`, `/operations`, `/system`, `/pools` | `extended` | Full catalog with `TokenListSurfaceNote` label |
|
||||||
@@ -97,7 +97,7 @@
|
|||||||
- **Wallet status (1639, 1722)** – `statusEl.innerHTML` uses `shortenHash(userAddress)`. If `userAddress` were ever from an untrusted source, it should be escaped. **Action:** Use `escapeHtml(shortenHash(userAddress))` for consistency (in **H1**).
|
- **Wallet status (1639, 1722)** – `statusEl.innerHTML` uses `shortenHash(userAddress)`. If `userAddress` were ever from an untrusted source, it should be escaped. **Action:** Use `escapeHtml(shortenHash(userAddress))` for consistency (in **H1**).
|
||||||
- **loadGasAndNetworkStats (2509)** – `el.innerHTML` uses `gasGwei`, `blockTimeSec`, `tps`. These are from API; escaping is low risk but recommended for defense in depth. **Action:** Escape these values (in **H1** or small follow-up).
|
- **loadGasAndNetworkStats (2509)** – `el.innerHTML` uses `gasGwei`, `blockTimeSec`, `tps`. These are from API; escaping is low risk but recommended for defense in depth. **Action:** Escape these values (in **H1** or small follow-up).
|
||||||
- **Token list: `#/token/' + contract`** – The `contract` in `href="#/token/' + contract + '"` can break the attribute if it contains a quote. **Action:** Encode or validate; include in **H2** (safe href/attributes).
|
- **Token list: `#/token/' + contract`** – The `contract` in `href="#/token/' + contract + '"` can break the attribute if it contains a quote. **Action:** Encode or validate; include in **H2** (safe href/attributes).
|
||||||
- **External link (3800)** – `'https://explorer.d-bis.org/address/' + addr + '/contract'` – `addr` should be validated or encoded so the URL cannot be malformed. **Action:** Use `encodeURIComponent(addr)` for the path segment (in **H2**).
|
- **External link (3800)** – `'https://explorer.d-bis.org/addresses/' + addr + '/contract'` – `addr` should be validated or encoded so the URL cannot be malformed. **Action:** Use `encodeURIComponent(addr)` for the path segment (in **H2**).
|
||||||
|
|
||||||
### 2.3 SPA: onclick and attribute injection
|
### 2.3 SPA: onclick and attribute injection
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,15 @@ describe('resolveExplorerApiBase', () => {
|
|||||||
).toBe('https://blockscout.defi-oracle.io')
|
).toBe('https://blockscout.defi-oracle.io')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses browser HTTPS origin when an explicit same-host HTTP value is present', () => {
|
||||||
|
expect(
|
||||||
|
resolveExplorerApiBase({
|
||||||
|
envValue: 'http://explorer.d-bis.org',
|
||||||
|
browserOrigin: 'https://explorer.d-bis.org',
|
||||||
|
})
|
||||||
|
).toBe('https://explorer.d-bis.org')
|
||||||
|
})
|
||||||
|
|
||||||
it('falls back to same-origin in the browser when env is empty', () => {
|
it('falls back to same-origin in the browser when env is empty', () => {
|
||||||
expect(
|
expect(
|
||||||
resolveExplorerApiBase({
|
resolveExplorerApiBase({
|
||||||
|
|||||||
@@ -4,19 +4,35 @@ function normalizeApiBase(value: string | null | undefined): string {
|
|||||||
return (value || '').trim().replace(/\/$/, '')
|
return (value || '').trim().replace(/\/$/, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function preferBrowserOriginForSameHost(explicitBase: string, browserOrigin: string): string {
|
||||||
|
if (!explicitBase || !browserOrigin) return explicitBase
|
||||||
|
|
||||||
|
try {
|
||||||
|
const explicitUrl = new URL(explicitBase)
|
||||||
|
const browserUrl = new URL(browserOrigin)
|
||||||
|
if (explicitUrl.hostname === browserUrl.hostname && explicitUrl.protocol !== browserUrl.protocol) {
|
||||||
|
return browserOrigin
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return explicitBase
|
||||||
|
}
|
||||||
|
|
||||||
|
return explicitBase
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveExplorerApiBase(options: {
|
export function resolveExplorerApiBase(options: {
|
||||||
envValue?: string | null
|
envValue?: string | null
|
||||||
browserOrigin?: string | null
|
browserOrigin?: string | null
|
||||||
serverFallback?: string
|
serverFallback?: string
|
||||||
} = {}): string {
|
} = {}): string {
|
||||||
const explicitBase = normalizeApiBase(options.envValue ?? process.env.NEXT_PUBLIC_API_URL ?? '')
|
const explicitBase = normalizeApiBase(options.envValue ?? process.env.NEXT_PUBLIC_API_URL ?? '')
|
||||||
if (explicitBase) {
|
|
||||||
return explicitBase
|
|
||||||
}
|
|
||||||
|
|
||||||
const browserOrigin = normalizeApiBase(
|
const browserOrigin = normalizeApiBase(
|
||||||
options.browserOrigin ?? (typeof window !== 'undefined' ? window.location.origin : '')
|
options.browserOrigin ?? (typeof window !== 'undefined' ? window.location.origin : '')
|
||||||
)
|
)
|
||||||
|
if (explicitBase) {
|
||||||
|
return preferBrowserOriginForSameHost(explicitBase, browserOrigin)
|
||||||
|
}
|
||||||
|
|
||||||
if (browserOrigin) {
|
if (browserOrigin) {
|
||||||
return browserOrigin
|
return browserOrigin
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ export function Card({ children, className, title }: CardProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'rounded-xl bg-white p-4 shadow-md dark:bg-gray-800 sm:p-6',
|
'rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/70 sm:p-5',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{title && (
|
{title && (
|
||||||
<h3 className="mb-3 text-lg font-semibold text-gray-900 dark:text-white sm:mb-4 sm:text-xl">
|
<h3 className="mb-3 text-base font-semibold text-gray-900 dark:text-white sm:text-lg">
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ interface TableProps<T> {
|
|||||||
data: T[]
|
data: T[]
|
||||||
className?: string
|
className?: string
|
||||||
emptyMessage?: string
|
emptyMessage?: string
|
||||||
|
/**
|
||||||
|
* responsive: stacked cards below `md`, table at md+.
|
||||||
|
* tabular: always use columnar HTML table (holder lists, dense numeric tables).
|
||||||
|
*/
|
||||||
|
layout?: 'responsive' | 'tabular'
|
||||||
/** Stable key for each row (e.g. row => row.id or row => row.hash). Falls back to index if not provided. */
|
/** Stable key for each row (e.g. row => row.id or row => row.hash). Falls back to index if not provided. */
|
||||||
keyExtractor?: (row: T) => string | number
|
keyExtractor?: (row: T) => string | number
|
||||||
}
|
}
|
||||||
@@ -21,6 +26,7 @@ export function Table<T>({
|
|||||||
data,
|
data,
|
||||||
className,
|
className,
|
||||||
emptyMessage = 'No data available right now.',
|
emptyMessage = 'No data available right now.',
|
||||||
|
layout = 'responsive',
|
||||||
keyExtractor,
|
keyExtractor,
|
||||||
}: TableProps<T>) {
|
}: TableProps<T>) {
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
@@ -36,9 +42,12 @@ export function Table<T>({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stackedClass = layout === 'tabular' ? 'hidden' : 'grid gap-3 md:hidden'
|
||||||
|
const tableWrapperClass = layout === 'tabular' ? 'overflow-x-auto' : 'hidden overflow-x-auto md:block'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx('space-y-3', className)}>
|
<div className={clsx('space-y-3', className)}>
|
||||||
<div className="grid gap-3 md:hidden">
|
<div className={stackedClass}>
|
||||||
{data.map((row, rowIndex) => (
|
{data.map((row, rowIndex) => (
|
||||||
<div
|
<div
|
||||||
key={keyExtractor ? keyExtractor(row) : rowIndex}
|
key={keyExtractor ? keyExtractor(row) : rowIndex}
|
||||||
@@ -60,7 +69,7 @@ export function Table<T>({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden overflow-x-auto md:block">
|
<div className={tableWrapperClass}>
|
||||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
4
frontend/next-env.d.ts
vendored
4
frontend/next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <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
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
|
const path = require('path')
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
|
outputFileTracingRoot: path.resolve(__dirname, '..', '..'),
|
||||||
async redirects() {
|
async redirects() {
|
||||||
return [
|
return [
|
||||||
|
{
|
||||||
|
source: '/tx/:hash',
|
||||||
|
destination: '/transactions/:hash',
|
||||||
|
permanent: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
source: '/more',
|
source: '/more',
|
||||||
destination: '/operations',
|
destination: '/operations',
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user