Compare commits

...

34 Commits

Author SHA1 Message Date
defiQUG
4fac5e4856 Fix UX audit gaps: tablet nav, footer, wallet connect, legacy demotion.
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 13s
Validate Explorer / frontend (push) Successful in 1m29s
Validate Explorer / smoke-e2e (push) Failing after 2m27s
Close the 1024–1279px nav dead zone, align ops/footer labels, split homepage quick links, route successful wallet connect to /wallet with inline errors, add WETH to ops sub-nav, and demote legacy SPA with noindex plus banner.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 22:30:35 -07:00
defiQUG
b213c6547d Add wallet auth smoke e2e and include WalletConnect in parity checks.
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 15s
Validate Explorer / frontend (push) Successful in 1m29s
Validate Explorer / smoke-e2e (push) Failing after 2m21s
Live API check confirms walletconnect config; dual-domain verifier now covers the deployed endpoint by default.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 22:06:15 -07:00
defiQUG
567b4647c0 Fix wallet connect signature mismatch on mobile and desktop.
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 14s
Validate Explorer / frontend (push) Successful in 1m26s
Validate Explorer / smoke-e2e (push) Failing after 2m19s
Align backend EIP-191 auth message with the DBIS Explorer text the frontend and legacy SPA already sign, instead of the stale SolaceScan string.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 22:01:11 -07:00
defiQUG
8a61b1bde2 Make WalletConnect parity check opt-in until backend deploy.
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 15s
Default dual-domain verifier skips walletconnect/config; set INCLUDE_WALLETCONNECT=1 after backend rollout.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 21:56:38 -07:00
defiQUG
f2ebe824bd Add WalletConnect stub, track surfaces, legacy SPA retirement, and dual-domain checks.
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 14s
Validate Explorer / frontend (push) Successful in 1m27s
Validate Explorer / smoke-e2e (push) Failing after 2m19s
Publish walletconnect config endpoints, Track 3/4 notes on analytics/operator pages, legacy SPA at /legacy/index.html with root redirect, and a parity verifier for explorer.d-bis.org vs blockscout.defi-oracle.io.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 21:55:42 -07:00
defiQUG
991d1bb07c Add mobile ops surface nav and footer public API links.
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 14s
Validate Explorer / frontend (push) Successful in 1m32s
Validate Explorer / smoke-e2e (push) Failing after 1m52s
Operations pages get collapsible surface navigation on small screens and a shared action-card accordion; the footer surfaces read-only JSON endpoints with e2e coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 21:39:08 -07:00
defiQUG
847cfeb48b feat(explorer): API-driven CCIP route catalog on bridge page
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 14s
Validate Explorer / frontend (push) Successful in 1m35s
Validate Explorer / smoke-e2e (push) Failing after 1m38s
Load destination bridge contracts from token-aggregation, add fallback polling,
extend smoke tests, and document bridge routes client helper.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 21:12:21 -07:00
defiQUG
6a64d2fec6 fix(explorer): harden operations smoke test and surface note placement
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 13s
Validate Explorer / frontend (push) Successful in 1m26s
Validate Explorer / smoke-e2e (push) Failing after 1m29s
Move extended token-list label to the operations intro, wait for network idle
before asserting, and clear conflicting NO_COLOR/FORCE_COLOR in Playwright config.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 20:53:27 -07:00
defiQUG
7a7dfca221 feat(explorer): mission-control resilience, ops token labels, and CI validate
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 14s
Validate Explorer / frontend (push) Successful in 1m34s
Validate Explorer / smoke-e2e (push) Failing after 1m26s
Add SSE reconnect with backoff, fallback REST polling, visibility-aware refresh,
extended token-list labels on operations pages, validate-on-pr workflow, and smoke coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 20:40:11 -07:00
defiQUG
e3ec87c324 feat(explorer): token-list surfaces, homepage trim, and sprint smoke tests
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 14s
Unify wallet/catalog/extended token-list policy, add contract verification CTA,
trim the homepage dashboard with status strip and recent activity, and add Playwright smoke coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 20:22:45 -07:00
defiQUG
0778c18e59 fix(explorer): read SSE stream until event and data lines arrive
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 15s
The health check stopped after two non-empty lines and missed the data line that follows event: ping on mission-control streams.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 18:05:47 -07:00
defiQUG
4b747f0309 feat(explorer): dynamic feeds, wallet SSR alignment, and detail pagination
Align wallet SSR with report token-list, dedupe featured v1 tokens, refresh home and wallet snapshots on a 60s cadence, and drive vanilla SPA chain add/watch from API metadata. Add shared pagination/tabs for address, token, and transaction pages, extend token aggregation helpers, and harden stats API with tests and health checks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 17:58:27 -07:00
defiQUG
ca1394c579 chore(explorer): run vitest in npm test
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 17s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 21:41:25 -07:00
defiQUG
e14b43e3fe test(explorer): expect recentTransactionTrend in loadDashboardData fixtures
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 17s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 21:40:44 -07:00
defiQUG
64e78dad47 feat(explorer): token signing surface card, ERC-5267 domain read, tabular top holders
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 15s
- Add TokenSigningSurfaceCard: ABI flags, eip712Domain eth_call decode, verification metadata
- Pass contract profile into GRU standards detection on token page
- Table layout=tabular for Top Holders column layout at all breakpoints
- Fallback provenance name/symbol; show signing card when token API empty
- eip712Domain.ts: decode ERC-5267 tuple return data

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 21:09:40 -07:00
defiQUG
654933cb36 fix(explorer): normalize token market liquidityUsd client-side
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 13s
- Mirror token-aggregation liquidity scaling in tokenAggregation API layer
- Tokens page and shared brand/layout tweaks
- deploy-live workflow adjustment

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 12:55:08 -07:00
defiQUG
d4f922c26e chore: metamask networks, explorer SPA, nginx scripts; ignore Python cache
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 12s
- Dual-chain / GRU deployment JSON sync
- Frontend explorer SPA + MetaMask components
- Scripts: nginx fixes, link deploy, local SPA serve helper
- Token icon chain-138.png; .gitignore __pycache__

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 13:00:43 -07:00
e5df7c2ea3 Merge pull request 'feat: institutional membership tiers and corrected member directory' (#16) from devin/1778358341-institutional-membership-tiers into master
All checks were successful
phoenix-deploy Deployed to explorer-live
Deploy Explorer Live / deploy (push) Successful in 2m58s
2026-05-09 21:01:16 +00:00
Devin AI
9e17ed8ceb fix: remove BIS Innovation Hub from member directory
Co-Authored-By: Nakamoto, S <defi@defi-oracle.io>
2026-05-09 20:48:21 +00:00
Devin AI
55a209646a feat: add institutional membership tiers and correct member directory
Corrections per 2026-04 institutional review:
- MLFO reclassified as Global Family Office (was incorrectly labeled central bank)
- BIS Innovation Hub reclassified as Standards Body (does not hold observer seat)
- Added missing entities: ICCC, SAID, PANDA, Order of Hospitallers (XOM)
- Added BRICS founding + expanded member central banks (10 entries)

New institutional tier taxonomy (7 tiers):
  sovereign_central_bank, global_family_office, settlement_member,
  infrastructure_operator, oversight_judicial, delegated_authority,
  standards_body

Backend changes:
- New auth/membership.go: tier types, DefaultTrackForTier mapping,
  MembershipStore with DB queries for member directory
- New migration 0017: institutional_members + institutional_member_wallets
  tables with seed data for all corrected members
- Updated wallet_auth.go getUserTrack(): now resolves institutional
  membership (via wallet junction table) before defaulting to Track 1
- WalletAuthResponse now includes institutional_tier and institution_name
- New REST endpoints: GET /api/v1/membership/{tiers,members,members/:slug}
- Added TrackLabel() helper in featureflags

Frontend changes:
- Added InstitutionalTier type and label map to access.ts
- WalletAccessSession extended with institutionalTier/institutionName
- Navbar getAccessTier() now displays institutional tier label when present
- Session summary shows institution name

Co-Authored-By: Nakamoto, S <defi@defi-oracle.io>
2026-05-09 20:32:06 +00:00
defiQUG
e397245ec9 Add market evidence notes to explorer surfaces
All checks were successful
phoenix-deploy Deployed to explorer-live
Deploy Explorer Live / deploy (push) Successful in 3m6s
2026-04-30 03:53:13 -07:00
defiQUG
8cd8bfa195 Unify explorer DBIS taxonomy and branding
All checks were successful
phoenix-deploy Deployed to explorer-live
Deploy Explorer Live / deploy (push) Successful in 2m18s
2026-04-30 03:06:49 -07:00
defiQUG
3b7e24080f Refresh Next type environment
Some checks failed
phoenix-deploy Deploy failed: Command failed: bash scripts/deployment/phoenix-deploy-explorer-live-from-workspace.sh nginx: the configuration file /et
Deploy Explorer Live / deploy (push) Failing after 1m48s
2026-04-30 01:58:15 -07:00
defiQUG
ba08199051 Align GRU explorer terminology
Some checks failed
Deploy Explorer Live / deploy (push) Has been cancelled
2026-04-30 01:57:51 -07:00
defiQUG
0ba2a70c34 Refresh token metadata categories
Some checks failed
Deploy Explorer Live / deploy (push) Has been cancelled
phoenix-deploy Deployed to explorer-live
2026-04-30 01:57:12 -07:00
defiQUG
ac40184d6b Fix SolaceScan frontend service release path
All checks were successful
phoenix-deploy Deployed to explorer-live
Deploy Explorer Live / deploy (push) Successful in 2m10s
2026-04-29 06:42:20 -07:00
defiQUG
7a16ddccf7 Add verified contract source workspace
Some checks failed
phoenix-deploy Deploy failed: Command failed: bash scripts/deployment/phoenix-deploy-explorer-live-from-workspace.sh nginx: the configuration file /et
Deploy Explorer Live / deploy (push) Failing after 3m47s
2026-04-29 06:21:56 -07:00
defiQUG
1f5167aded Expose full verified contract source payloads 2026-04-29 06:21:36 -07:00
defiQUG
f5eb874210 Harden VMID 5000 frontend deploy server discovery 2026-04-29 06:19:32 -07:00
defiQUG
1aa81f454a feat(explorer): add live token/native pricing and legacy tx route compatibility
Some checks failed
phoenix-deploy Deploy failed: Command failed: bash scripts/deployment/phoenix-deploy-explorer-live-from-workspace.sh nginx: the configuration file /et
Deploy Explorer Live / deploy (push) Failing after 4m8s
2026-04-25 23:45:07 -07:00
Codex
1b5cebf505 Add Gitea live redeploy workflow
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 8s
phoenix-deploy Deployed to explorer-live
2026-04-23 09:51:01 -07:00
fe9edd842b Merge pull request 'security: tighten gitleaks regex + document history-purge audit trail' (#14) from devin/1776542851-harden-gitleaks-and-document-purge into master
Some checks failed
CI / Backend (go 1.23.x) (push) Successful in 51s
CI / Backend security scanners (push) Failing after 45s
CI / Frontend (node 20) (push) Successful in 2m5s
CI / gitleaks (secret scan) (push) Failing after 7s
e2e-full / e2e-full (push) Failing after 21s
2026-04-18 20:08:58 +00:00
fdb14dc420 security: tighten gitleaks regex for escaped form, document history-purge audit trail
Some checks failed
CI / Backend (go 1.23.x) (pull_request) Successful in 56s
CI / Backend security scanners (pull_request) Failing after 40s
CI / Frontend (node 20) (pull_request) Successful in 2m19s
CI / gitleaks (secret scan) (pull_request) Failing after 7s
e2e-full / e2e-full (pull_request) Has been skipped
Two small follow-ups to the out-of-band git-history rewrite that
purged L@ker$2010 / L@kers2010 / L@ker\$2010 from every branch and
tag:

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

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

Verification:
  gitleaks detect --no-git --source . --config .gitleaks.toml   # 0 legacy hits
  git log --all -p | grep -cE 'L@ker\$2010|L@kers2010'         # 0
2026-04-18 20:08:13 +00:00
7c018965eb Merge pull request 'fix(scripts): require DB_PASSWORD env var in setup-database.sh' (#13) from devin/1776542488-fix-setup-database-hardcoded-password into master
Some checks failed
CI / Backend (go 1.23.x) (push) Has been cancelled
CI / Backend security scanners (push) Has been cancelled
CI / Frontend (node 20) (push) Has been cancelled
CI / gitleaks (secret scan) (push) Has been cancelled
2026-04-18 20:02:37 +00:00
142 changed files with 20678 additions and 9228 deletions

View 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\"}"

View 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

4
.gitignore vendored
View File

@@ -55,6 +55,10 @@ backend/bin/
backend/api/rest/cmd/api-server
backend/cmd
# Python
__pycache__/
*.py[cod]
# Tooling / scratch directories
out/
cache/

View File

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

View File

@@ -5,7 +5,7 @@
"minor": 1,
"patch": 0
},
"generatedBy": "SolaceScan",
"generatedBy": "DBIS Explorer",
"timestamp": "2026-03-28T00:00:00Z",
"chainId": 138,
"chainName": "DeFi Oracle Meta Mainnet",

View File

@@ -3,10 +3,10 @@
"version": {"major": 1, "minor": 2, "patch": 0},
"defaultChainId": 138,
"explorerUrl": "https://explorer.d-bis.org",
"tokenListUrl": "https://explorer.d-bis.org/api/config/token-list",
"generatedBy": "SolaceScan",
"tokenListUrl": "https://explorer.d-bis.org/api/v1/report/token-list?chainId=138",
"generatedBy": "DBIS Explorer",
"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":"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"]},

View File

@@ -485,7 +485,7 @@
],
"blockers": [
"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."
],
"resolutionMatrix": [
@@ -540,7 +540,7 @@
{
"key": "wave1_transport_pending",
"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": [
{
"code": "EUR",
@@ -614,7 +614,7 @@
],
"resolution": [
"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."
],
"runbooks": [
@@ -623,7 +623,7 @@
"scripts/verify/check-gru-global-priority-rollout.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",
@@ -801,9 +801,9 @@
}
],
"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.",
"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": [
"config/gru-global-priority-currency-rollout.json",
@@ -827,7 +827,7 @@
"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.",
"Define and implement SPL mint authority / bridge program wiring; record solanaMint for each asset.",
"Replace SolanaRelayService stub with production relay; mainnet-beta E2E both directions.",
"Add dedicated verifier coverage and only then promote Solana into active transport inventory and public status surfaces."
"Add dedicated verifier coverage and only then promote Solana into active public-network inventory and public status surfaces."
],
"runbooks": [
"config/solana-gru-bridge-lineup.json",
@@ -842,7 +842,7 @@
}
],
"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."
]
}

View File

@@ -129,7 +129,7 @@
"coveredSymbols": 10,
"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": {
"liveTransportAssets": [
@@ -265,7 +265,7 @@
"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": {
"publicCwMesh": [

View 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,
})
}

View File

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

View File

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

View File

@@ -54,6 +54,8 @@ func (s *Server) SetupRoutes(mux *http.ServeMux) {
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/login", s.handleAuthLogin)
mux.HandleFunc("/api/v1/access/me", s.handleAccessMe)
@@ -67,6 +69,11 @@ func (s *Server) SetupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/access/usage", s.handleAccessUsage)
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)
// Note: Track 1 endpoints should be registered with OptionalAuth middleware
// mux.HandleFunc("/api/v1/track1/blocks/latest", s.track1Server.handleLatestBlocks)

View File

@@ -11,6 +11,7 @@ import (
"time"
"github.com/explorer/backend/api/freshness"
"github.com/jackc/pgx/v5"
)
type explorerStats struct {
@@ -34,6 +35,14 @@ type explorerGasPrices struct {
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) {
var value sql.NullFloat64
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
}
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
func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w)
return
}
if !s.requireDB(w) {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
stats, err := loadExplorerStats(ctx, s.chainID, s.db.QueryRow)
if err != nil {
writeError(w, http.StatusServiceUnavailable, "service_unavailable", "explorer stats are temporarily unavailable")
return
var stats explorerStats
if s.db == nil {
stats = loadExplorerStatsFallback(ctx, s.chainID, fmt.Errorf("database pool is not configured"))
} 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")

View File

@@ -136,3 +136,33 @@ func TestLoadExplorerStatsReturnsErrorWhenQueryFails(t *testing.T) {
require.Error(t, err)
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")
}

View File

@@ -0,0 +1,107 @@
package rest
import (
"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)
}
// 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.StatusNotImplemented, 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 "":
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",
"/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")
}
}

View File

@@ -0,0 +1,79 @@
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 TestHandleWalletConnectConnectStub(t *testing.T) {
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)
}
var payload map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload["status"] != "stub" {
t.Fatalf("expected stub status, got %#v", payload["status"])
}
}
func TestHandleWalletConnectSessionStub(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.StatusNotImplemented {
t.Fatalf("expected 501, got %d", rec.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)
}
}

213
backend/auth/membership.go Normal file
View 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
}

View File

@@ -102,10 +102,18 @@ type WalletAuthRequest struct {
// WalletAuthResponse represents a wallet authentication response
type WalletAuthResponse struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
Track int `json:"track"`
Permissions []string `json:"permissions"`
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
Track int `json:"track"`
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
@@ -182,7 +190,7 @@ func (w *WalletAuth) AuthenticateWallet(ctx context.Context, req *WalletAuthRequ
}
// 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))
sigBytes, err := decodeWalletSignature(req.Signature)
@@ -223,17 +231,30 @@ func (w *WalletAuth) AuthenticateWallet(ctx context.Context, req *WalletAuthRequ
// Get permissions for track
permissions := getPermissionsForTrack(track)
return &WalletAuthResponse{
resp := &WalletAuthResponse{
Token: token,
ExpiresAt: expiresAt,
Track: track,
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) {
// Check if user exists in operator_roles (Track 4)
// 1. Check explicit per-address assignment in operator_roles
var track int
var approved bool
query := `SELECT track_level, approved FROM operator_roles WHERE address = $1`
@@ -242,9 +263,20 @@ func (w *WalletAuth) getUserTrack(ctx context.Context, address string) (int, err
return track, nil
}
// Check if user is approved for Track 2 or 3
// For now, default to Track 1 (public)
// In production, you'd have an approval table
// 2. Check institutional membership
var tier string
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
}

View File

@@ -5,9 +5,42 @@ import (
"testing"
"time"
"github.com/ethereum/go-ethereum/accounts"
"github.com/ethereum/go-ethereum/crypto"
"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) {
_, err := decodeWalletSignature("deadbeef")
require.ErrorContains(t, err, "signature must start with 0x")

View File

@@ -25,7 +25,9 @@
"https://explorer.d-bis.org"
],
"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"
]
},
{
@@ -90,4 +92,4 @@
]
}
]
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -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;

View 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;

View File

@@ -118,3 +118,19 @@ func GetAllFeatures() map[string]FeatureFlag {
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"
}
}

View File

@@ -3,35 +3,122 @@ package wallet
import (
"context"
"fmt"
"os"
"strings"
"time"
)
// WalletConnect handles WalletConnect v2 integration
const (
WalletConnectStatusStub = "stub"
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 {
projectID string
relayURL string
chainID int
}
// NewWalletConnect creates a new WalletConnect handler
func NewWalletConnect(projectID string) *WalletConnect {
return &WalletConnect{projectID: projectID}
// NewWalletConnect creates a WalletConnect handler using deployment env vars.
func NewWalletConnect(chainID int) *WalletConnect {
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) Connect(ctx context.Context) (string, error) {
// Implementation would use WalletConnect v2 SDK
// Returns connection URI for QR code display
return "", fmt.Errorf("not implemented - requires WalletConnect SDK")
func (wc *WalletConnect) enabled() bool {
return wc.projectID != ""
}
// Session represents a wallet session
type Session struct {
Address string
ChainID int
Connected bool
// PublicConfig returns the read-only WalletConnect config surface for clients.
func (wc *WalletConnect) PublicConfig() Config {
status := WalletConnectStatusStub
if !wc.enabled() {
status = WalletConnectStatusDisabled
}
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) GetSession(ctx context.Context, sessionID string) (*Session, error) {
// Implementation would retrieve session from WalletConnect
return nil, fmt.Errorf("not implemented")
func (wc *WalletConnect) publicMessage() string {
if wc.enabled() {
return "WalletConnect v2 config is published, but session bridging is still stubbed. Use browser wallet auth at /api/v1/auth/wallet until mobile QR sessions ship."
}
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 initiates a wallet connection. Live QR sessions are not implemented yet.
func (wc *WalletConnect) Connect(_ context.Context) (*ConnectResponse, error) {
return &ConnectResponse{
Status: WalletConnectStatusStub,
Enabled: wc.enabled(),
FallbackAuth: "/api/v1/auth/wallet",
Message: "WalletConnect session creation is stubbed. Use browser extension wallet auth until the relay bridge is enabled.",
}, fmt.Errorf("walletconnect session bridge not implemented")
}
// GetSession gets a wallet session snapshot. Storage is not implemented yet.
func (wc *WalletConnect) GetSession(_ context.Context, sessionID string) (*Session, error) {
if strings.TrimSpace(sessionID) == "" {
return nil, fmt.Errorf("session id is required")
}
return &Session{
SessionID: sessionID,
Connected: false,
Status: WalletConnectStatusStub,
Message: "WalletConnect session lookup is stubbed.",
}, fmt.Errorf("walletconnect session storage not implemented")
}

View File

@@ -479,7 +479,7 @@ EOF
```bash
cat > /etc/systemd/system/solacescanscout-frontend.service << 'EOF'
[Unit]
Description=SolaceScan Next Frontend Service
Description=DBIS Explorer Next Frontend Service
After=network.target explorer-api.service
Requires=explorer-api.service

View File

@@ -1,6 +1,6 @@
# 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?"

View File

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

View File

@@ -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:
# - /api/
# - /api/config/token-list
@@ -12,6 +12,12 @@
# Include these locations after those API/static locations and before any legacy
# 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/ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;

View File

@@ -1,5 +1,5 @@
[Unit]
Description=SolaceScan Next Frontend Service
Description=DBIS Explorer Next Frontend Service
After=network.target
Wants=network.target

View File

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

View File

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

View File

@@ -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 |

View File

@@ -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**).
- **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).
- **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

View File

@@ -11,6 +11,15 @@ describe('resolveExplorerApiBase', () => {
).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', () => {
expect(
resolveExplorerApiBase({

View File

@@ -4,19 +4,35 @@ function normalizeApiBase(value: string | null | undefined): string {
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: {
envValue?: string | null
browserOrigin?: string | null
serverFallback?: string
} = {}): string {
const explicitBase = normalizeApiBase(options.envValue ?? process.env.NEXT_PUBLIC_API_URL ?? '')
if (explicitBase) {
return explicitBase
}
const browserOrigin = normalizeApiBase(
options.browserOrigin ?? (typeof window !== 'undefined' ? window.location.origin : '')
)
if (explicitBase) {
return preferBrowserOriginForSameHost(explicitBase, browserOrigin)
}
if (browserOrigin) {
return browserOrigin
}

View File

@@ -11,12 +11,12 @@ export function Card({ children, className, title }: CardProps) {
return (
<div
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
)}
>
{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}
</h3>
)}

View File

@@ -12,6 +12,11 @@ interface TableProps<T> {
data: T[]
className?: 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. */
keyExtractor?: (row: T) => string | number
}
@@ -21,6 +26,7 @@ export function Table<T>({
data,
className,
emptyMessage = 'No data available right now.',
layout = 'responsive',
keyExtractor,
}: TableProps<T>) {
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 (
<div className={clsx('space-y-3', className)}>
<div className="grid gap-3 md:hidden">
<div className={stackedClass}>
{data.map((row, rowIndex) => (
<div
key={keyExtractor ? keyExtractor(row) : rowIndex}
@@ -60,7 +69,7 @@ export function Table<T>({
))}
</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">
<thead className="bg-gray-50 dark:bg-gray-800">
<tr>

View File

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

View File

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

16155
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -3,8 +3,8 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Acknowledgments | SolaceScan</title>
<meta name="description" content="Acknowledgments for the SolaceScan Chain 138 explorer.">
<title>Acknowledgments | DBIS Explorer</title>
<meta name="description" content="Acknowledgments for the DBIS Explorer Chain 138 explorer.">
<style>
body { margin: 0; font-family: Arial, Helvetica, sans-serif; background: linear-gradient(180deg, #0f172a 0%, #111827 45%, #f8fafc 46%, #ffffff 100%); color: #0f172a; }
.shell { max-width: 980px; margin: 0 auto; padding: 2rem 1rem 3rem; }
@@ -19,7 +19,7 @@
<body>
<div class="shell">
<div class="topbar">
<div class="brand">SolaceScan Acknowledgments</div>
<div class="brand">DBIS Explorer Acknowledgments</div>
<a href="/">Back to explorer</a>
</div>
<div class="card">
@@ -28,10 +28,10 @@
<ul>
<li><strong>Blockscout</strong> for explorer indexing and API compatibility.</li>
<li><strong>MetaMask</strong> for wallet connectivity and Snap support.</li>
<li><strong>Chainlink CCIP</strong> for bridge-related routing, transport, and companion operational surfaces where applicable.</li>
<li><strong>Chainlink CCIP</strong> for bridge-related routing, cW public-network representations, and companion operational surfaces where applicable.</li>
<li><strong>ethers.js</strong> for wallet and Ethereum interaction support.</li>
<li><strong>Font Awesome</strong> for iconography.</li>
<li><strong>Next.js</strong> and the frontend contributors supporting the DBIS / Defi Oracle explorer experience.</li>
<li><strong>Next.js</strong> and the frontend contributors supporting the DBIS explorer experience.</li>
</ul>
<p class="muted">If we have missed a contributor or dependency, please let us know at <a href="mailto:support@d-bis.org">support@d-bis.org</a>.</p>
</div>

View File

@@ -206,7 +206,7 @@ flowchart TB
subgraph CCIP_L2["Other live CCIP EVM destinations"]
L2CLU["OP 10 · Base 8453 · Arb 42161 · Polygon 137 · BSC 56 · Avax 43114 · Gnosis 100 · Celo 42220 · Cronos 25"]
LEAF_L2["Leaf — per-chain native DEX · cW token transport · partial edge pools"]
LEAF_L2["Leaf — per-chain native DEX · cW public-network representation · partial edge pools"]
end
subgraph ALLTRA["ALL Mainnet 651940"]
@@ -404,9 +404,9 @@ flowchart LR
<!-- 4 Cross-chain -->
<div class="content" id="panel-4" role="tabpanel" aria-labelledby="tab-4" hidden>
<p class="panel-desc">CCIP transport, Alltra round-trip, the dedicated c-to-cW mint corridors, and the orchestrated swap-bridge-swap target.</p>
<p class="panel-desc">CCIP routing, Alltra round-trip, the dedicated c-to-cW mint corridors, and the orchestrated swap-bridge-swap target.</p>
<div class="mermaid-wrap">
<h3>CCIP — WETH primary transport</h3>
<h3>CCIP — WETH primary routing lane</h3>
<div class="mermaid">
sequenceDiagram
participant U as User or bot

View File

@@ -3,8 +3,8 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Documentation Redirect | SolaceScan</title>
<meta name="description" content="Redirecting to the canonical SolaceScan documentation hub.">
<title>Documentation Redirect | DBIS Explorer</title>
<meta name="description" content="Redirecting to the canonical DBIS Explorer documentation hub.">
<meta http-equiv="refresh" content="0; url=/docs">
<link rel="canonical" href="https://blockscout.defi-oracle.io/docs">
<style>
@@ -23,7 +23,7 @@
<body>
<div class="shell">
<div class="topbar">
<div class="brand">SolaceScan Documentation</div>
<div class="brand">DBIS Explorer Documentation</div>
<a href="/">Back to explorer</a>
</div>
<div class="card">

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -3,8 +3,8 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Privacy Policy | SolaceScan</title>
<meta name="description" content="Privacy policy for the SolaceScan Chain 138 explorer operated by DBIS / Defi Oracle.">
<title>Privacy Policy | DBIS Explorer</title>
<meta name="description" content="Privacy policy for the DBIS Explorer Chain 138 explorer operated by DBIS.">
<style>
body { margin: 0; font-family: Arial, Helvetica, sans-serif; background: linear-gradient(180deg, #0f172a 0%, #111827 45%, #f8fafc 46%, #ffffff 100%); color: #0f172a; }
.shell { max-width: 980px; margin: 0 auto; padding: 2rem 1rem 3rem; }
@@ -19,13 +19,13 @@
<body>
<div class="shell">
<div class="topbar">
<div class="brand">SolaceScan Privacy Policy</div>
<div class="brand">DBIS Explorer Privacy Policy</div>
<a href="/">Back to explorer</a>
</div>
<div class="card">
<h1 style="margin-top:0;">Privacy Policy</h1>
<p class="muted">Last updated: 2026-03-25</p>
<p>SolaceScan is the public Chain 138 explorer surface operated by DBIS / Defi Oracle. Most content you view comes from public blockchain data, explorer indexers, route services, and public configuration endpoints. We do not ask for personal information to browse the public explorer.</p>
<p>DBIS Explorer is the public Chain 138 explorer surface operated by DBIS. Most content you view comes from public blockchain data, explorer indexers, route services, and public configuration endpoints. We do not ask for personal information to browse the public explorer.</p>
<h2>What we store locally</h2>
<ul>
<li>We may store theme preference, locale, recent searches, and similar local UI settings in your browser.</li>

View File

@@ -3,8 +3,8 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Terms of Service | SolaceScan</title>
<meta name="description" content="Terms of service for the SolaceScan Chain 138 explorer operated by DBIS / Defi Oracle.">
<title>Terms of Service | DBIS Explorer</title>
<meta name="description" content="Terms of service for the DBIS Explorer Chain 138 explorer operated by DBIS.">
<style>
body { margin: 0; font-family: Arial, Helvetica, sans-serif; background: linear-gradient(180deg, #0f172a 0%, #111827 45%, #f8fafc 46%, #ffffff 100%); color: #0f172a; }
.shell { max-width: 980px; margin: 0 auto; padding: 2rem 1rem 3rem; }
@@ -19,13 +19,13 @@
<body>
<div class="shell">
<div class="topbar">
<div class="brand">SolaceScan Terms of Service</div>
<div class="brand">DBIS Explorer Terms of Service</div>
<a href="/">Back to explorer</a>
</div>
<div class="card">
<h1 style="margin-top:0;">Terms of Service</h1>
<p class="muted">Last updated: 2026-03-25</p>
<p>SolaceScan is provided for informational and operational purposes by DBIS / Defi Oracle. By using the public explorer, wallet tools, docs, and linked companion resources, you agree that:</p>
<p>DBIS Explorer is provided for informational and operational purposes by DBIS. By using the public explorer, wallet tools, docs, and linked companion resources, you agree that:</p>
<h2>Service scope</h2>
<ul>
<li>Blockchain data may be delayed, incomplete, or temporarily unavailable.</li>
@@ -55,7 +55,7 @@
<li>Bridge, route, liquidity, and operational surfaces are investigative and informational unless a page explicitly presents an authenticated management workflow.</li>
</ul>
<h2>Operator identity</h2>
<p>SolaceScan is operated by DBIS / Defi Oracle. Public explorer access may appear under <code>blockscout.defi-oracle.io</code>, while companion resources may appear under <code>explorer.d-bis.org</code> and related DBIS domains.</p>
<p>DBIS Explorer is operated by DBIS. Public explorer access may appear under <code>blockscout.defi-oracle.io</code>, while companion resources may appear under <code>explorer.d-bis.org</code> and related DBIS domains.</p>
<h2>Support and notices</h2>
<p>For service questions, operational issues, or policy notices, contact <a href="mailto:support@d-bis.org">support@d-bis.org</a>.</p>
<h2>Disputes and interpretation</h2>

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

View File

@@ -4,7 +4,7 @@ const baseUrl = (process.env.BASE_URL || 'https://explorer.d-bis.org').replace(/
const addressUnderTest = process.env.SMOKE_ADDRESS || '0x99b3511a2d315a497c8112c1fdd8d508d4b1e506'
const checks = [
{ path: '/', expectTexts: ['SolaceScan', 'Recent Blocks', 'Open wallet tools'] },
{ path: '/', expectTexts: ['DBIS Explorer', 'Recent Blocks', 'Open wallet tools'] },
{ path: '/blocks', expectTexts: ['Blocks'] },
{ path: '/transactions', expectTexts: ['Transactions'] },
{ path: '/addresses', expectTexts: ['Addresses', 'Open An Address'] },

View File

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

View File

@@ -8,15 +8,15 @@ export default function BrandLockup({ compact = false }: { compact?: boolean })
<span
className={[
'block truncate font-semibold tracking-[-0.02em] text-gray-950 dark:text-white',
compact ? 'text-[1.45rem]' : 'text-[1.65rem]',
compact ? 'text-[1.2rem]' : 'text-[1.35rem]',
].join(' ')}
>
SolaceScan
DBIS Explorer
</span>
<span
className={[
'block truncate font-medium uppercase text-gray-500 dark:text-gray-400',
compact ? 'text-[0.72rem] tracking-[0.14em]' : 'text-[0.8rem] tracking-[0.12em]',
compact ? 'text-[0.64rem] tracking-[0.13em]' : 'text-[0.68rem] tracking-[0.12em]',
].join(' ')}
>
Chain 138 Explorer by DBIS

View File

@@ -1,9 +1,9 @@
export default function BrandMark({ size = 'default' }: { size?: 'default' | 'compact' }) {
const containerClassName =
size === 'compact'
? 'h-10 w-10 rounded-xl'
: 'h-11 w-11 rounded-2xl'
const iconClassName = size === 'compact' ? 'h-6 w-6' : 'h-7 w-7'
? 'h-9 w-9 rounded-lg'
: 'h-10 w-10 rounded-lg'
const iconClassName = size === 'compact' ? 'h-5 w-5' : 'h-6 w-6'
return (
<span

View File

@@ -13,26 +13,44 @@ function toneClasses(tone: 'neutral' | 'success' | 'warning' | 'info') {
}
}
export function getEntityBadgeTone(tag: string): 'neutral' | 'success' | 'warning' | 'info' {
const normalized = tag.toLowerCase()
if (normalized === 'compliant' || normalized === 'listed' || normalized === 'verified') {
function normalizeBadgeLabel(value: unknown): string {
if (typeof value === 'string') return value
if (typeof value === 'number' || typeof value === 'bigint' || typeof value === 'boolean') return String(value)
return 'unknown'
}
export function getEntityBadgeTone(tag: unknown): 'neutral' | 'success' | 'warning' | 'info' {
const normalized = normalizeBadgeLabel(tag).toLowerCase()
if (normalized === 'compliant' || normalized === 'listed' || normalized === 'verified' || normalized === 'gru') {
return 'success'
}
if (normalized === 'wrapped') {
if (normalized === 'wrapped' || normalized === 'treasury-bond') {
return 'warning'
}
if (normalized === 'bridge' || normalized === 'canonical' || normalized === 'official') {
if (normalized === 'bridge' || normalized === 'canonical' || normalized === 'official' || normalized === 'electronic-money' || normalized === 'commodity') {
return 'info'
}
return 'neutral'
}
export function formatEntityBadgeLabel(label: unknown): string {
const resolvedLabel = normalizeBadgeLabel(label)
const normalized = resolvedLabel.toLowerCase()
const labels: Record<string, string> = {
'reference-asset': 'reference asset',
'electronic-money': 'cash e-money',
'treasury-bond': 'treasury / gov bond',
gru: 'GRU',
}
return labels[normalized] || resolvedLabel
}
export default function EntityBadge({
label,
tone,
className,
}: {
label: string
label: unknown
tone?: 'neutral' | 'success' | 'warning' | 'info'
className?: string
}) {
@@ -46,7 +64,7 @@ export default function EntityBadge({
className,
)}
>
{label}
{formatEntityBadgeLabel(label)}
</span>
)
}

View File

@@ -25,7 +25,7 @@ export default function ExplorerAgentTool() {
{
role: 'assistant',
content:
'Explorer AI Agent Tool is ready. I can explain this page, summarize what you are looking at, and help investigate transactions, contracts, routes, and system surfaces.',
'DBIS Explorer AI Assist is ready. I can explain this page, summarize what you are looking at, and help investigate transactions, contracts, routes, and system surfaces.',
},
])
@@ -96,7 +96,7 @@ export default function ExplorerAgentTool() {
<section className="w-[min(24rem,calc(100vw-1.5rem))] overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-2xl dark:border-gray-700 dark:bg-gray-900">
<div className="flex items-start justify-between gap-3 border-b border-gray-200 px-4 py-3 dark:border-gray-700">
<div>
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">Explorer AI Agent Tool</h2>
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">DBIS Explorer AI Assist</h2>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Page-aware guidance for the explorer. Helpful, read-only, and designed for quick investigation.
</p>
@@ -163,15 +163,16 @@ export default function ExplorerAgentTool() {
<button
type="button"
onClick={() => setOpen((value) => !value)}
className="inline-flex items-center gap-2 rounded-full bg-primary-600 px-4 py-3 text-sm font-semibold text-white shadow-lg transition hover:bg-primary-700"
className="inline-flex items-center gap-2 rounded-full bg-primary-600 p-3 text-sm font-semibold text-white shadow-lg transition hover:bg-primary-700 lg:px-4 lg:py-3"
aria-expanded={open}
aria-label="Open DBIS Explorer AI Assist"
>
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-white/15">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-4l-4 4v-4Z" />
</svg>
</span>
Agent Tool
<span className="hidden lg:inline">AI Assist</span>
</button>
</div>
)

View File

@@ -1,4 +1,5 @@
import Link from 'next/link'
import { explorerPublicApiLinks } from '@/data/explorerOperations'
const footerLinkClass =
'text-gray-600 dark:text-gray-400 hover:text-primary-600 dark:hover:text-primary-400 transition-colors'
@@ -9,21 +10,21 @@ export default function Footer() {
return (
<footer className="mt-auto border-t border-gray-200 dark:border-gray-700 bg-white/90 dark:bg-gray-900/90 backdrop-blur">
<div className="container mx-auto px-4 py-6 sm:py-8">
<div className="grid gap-4 sm:gap-6 md:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)_minmax(0,1fr)]">
<div className="grid gap-4 sm:gap-6 md:grid-cols-2 xl:grid-cols-4">
<div className="space-y-3 rounded-xl border border-gray-200 bg-gray-50/80 p-4 dark:border-gray-800 dark:bg-gray-900/40 md:border-0 md:bg-transparent md:p-0">
<div className="text-base font-semibold text-gray-900 dark:text-white sm:text-lg">
SolaceScan
DBIS Explorer
</div>
<p className="max-w-xl text-sm leading-6 text-gray-600 dark:text-gray-400">
Built on Blockscout for the DBIS / Defi Oracle Chain 138 explorer surface.
Built on Blockscout for the DBIS Chain 138 explorer surface.
Explorer data is powered by Blockscout, Chain 138 RPC, and the companion MetaMask Snap.
</p>
<p className="max-w-xl text-xs leading-5 text-gray-500 dark:text-gray-500">
Public explorer access may appear under <code>blockscout.defi-oracle.io</code> or <code>explorer.d-bis.org</code>.
Both domains belong to the same DBIS / Defi Oracle explorer surface.
Primary public explorer access is served at <code>explorer.d-bis.org</code>.
<code> blockscout.defi-oracle.io</code> is the Blockscout companion domain for the same Chain 138 explorer surface.
</p>
<p className="text-xs text-gray-500 dark:text-gray-500">
© {year} DBIS / Defi Oracle. All rights reserved.
© {year} DBIS. All rights reserved.
</p>
</div>
@@ -34,18 +35,48 @@ export default function Footer() {
<ul className="space-y-2 text-sm">
<li><Link className={footerLinkClass} href="/search">Search</Link></li>
<li><Link className={footerLinkClass} href="/docs">Documentation</Link></li>
<li><Link className={footerLinkClass} href="/bridge">Bridge Monitoring</Link></li>
<li><Link className={footerLinkClass} href="/liquidity">Liquidity Access</Link></li>
<li><Link className={footerLinkClass} href="/routes">Routes</Link></li>
<li><Link className={footerLinkClass} href="/operations">Operations Hub</Link></li>
<li><Link className={footerLinkClass} href="/blocks">Blocks</Link></li>
<li><Link className={footerLinkClass} href="/transactions">Transactions</Link></li>
<li><Link className={footerLinkClass} href="/tokens">Tokens</Link></li>
<li><Link className={footerLinkClass} href="/addresses">Addresses</Link></li>
<li><Link className={footerLinkClass} href="/watchlist">Watchlist</Link></li>
<li><Link className={footerLinkClass} href="/access">Account access</Link></li>
<li><Link className={footerLinkClass} href="/wallet">Wallet tools</Link></li>
<li><Link className={footerLinkClass} href="/operations">Operations hub</Link></li>
<li><Link className={footerLinkClass} href="/bridge">Bridge</Link></li>
<li><Link className={footerLinkClass} href="/routes">Routes</Link></li>
<li><Link className={footerLinkClass} href="/liquidity">Liquidity</Link></li>
<li><Link className={footerLinkClass} href="/pools">Pools</Link></li>
<li><Link className={footerLinkClass} href="/analytics">Analytics</Link></li>
<li><Link className={footerLinkClass} href="/operator">Operator</Link></li>
<li><Link className={footerLinkClass} href="/system">System</Link></li>
<li><Link className={footerLinkClass} href="/weth">WETH</Link></li>
<li><a className={footerLinkClass} href="/privacy.html">Privacy Policy</a></li>
<li><a className={footerLinkClass} href="/terms.html">Terms of Service</a></li>
<li><a className={footerLinkClass} href="/acknowledgments.html">Acknowledgments</a></li>
</ul>
</div>
<div className="rounded-xl border border-gray-200 bg-gray-50/80 p-4 dark:border-gray-800 dark:bg-gray-900/40 md:border-0 md:bg-transparent md:p-0">
<div className="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Public APIs
</div>
<ul className="space-y-3 text-sm">
{explorerPublicApiLinks.map((link) => (
<li key={link.href}>
<a className={footerLinkClass} href={link.href} target="_blank" rel="noopener noreferrer">
{link.label}
</a>
<p className="mt-0.5 text-xs leading-5 text-gray-500 dark:text-gray-500">{link.description}</p>
</li>
))}
</ul>
<p className="mt-3 text-xs leading-5 text-gray-500 dark:text-gray-500">
Read-only JSON endpoints on the public explorer domain. No API key required.
</p>
</div>
<div className="rounded-xl border border-gray-200 bg-gray-50/80 p-4 dark:border-gray-800 dark:bg-gray-900/40 md:border-0 md:bg-transparent md:p-0">
<div className="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Contact

View File

@@ -12,11 +12,26 @@ const STANDARD_EXPLANATIONS: Record<string, string> = {
'ERC-2612': 'Permit support for signature-based approvals without a separate on-chain approve transaction.',
'ERC-3009': 'Authorization-based transfer model for signed payment flows without prior allowances.',
'ERC-5267': 'Discoverable EIP-712 domain introspection so wallets and relayers can inspect the signing domain cleanly.',
IeMoneyToken: 'Repo-native eMoney token methodology for issuance and redemption semantics.',
CashElectronicMoneyInterface: 'Repo-native GRU instrument methodology for issuance and redemption semantics.',
DeterministicStorageNamespace: 'Stable namespace for upgrade-aware policy, registry, and audit resolution.',
JurisdictionAndSupervisionMetadata: 'Governance, supervisory, disclosure, and reporting metadata required by the GRU operating model.',
}
const STANDARD_DISPLAY_LABELS: Record<string, string> = {
CashElectronicMoneyInterface: 'Cash electronic-money interface',
DeterministicStorageNamespace: 'Deterministic storage namespace',
JurisdictionAndSupervisionMetadata: 'Jurisdiction and supervision metadata',
}
function formatStandardLabel(id: string): string {
return STANDARD_DISPLAY_LABELS[id] || id
}
function formatProfileLabel(id: string): string {
if (id === 'gru-c-star-v2-public-network-and-payment') return 'GRU C* v2 payment profile'
return id
}
function formatDuration(seconds: number | null): string | null {
if (seconds == null || !Number.isFinite(seconds) || seconds <= 0) return null
const units = [
@@ -56,16 +71,16 @@ export default function GruStandardsCard({
? `Review the live contract ABI and deployment against the GRU v2 base-token matrix before treating this asset as fully canonical.`
: `The live contract exposes the full required GRU v2 base-token surface currently checked by the explorer.`,
profile.wrappedTransport
? 'This looks like a wrapped transport asset, so confirm the corresponding bridge lane and reserve-verifier posture in addition to the token ABI.'
: 'This looks like a canonical GRU asset, so the next meaningful checks are reserve, governance, and transport activation beyond the token interface itself.',
? 'This looks like a cW public-network representation, so confirm the corresponding bridge lane and reserve-verifier posture in addition to the token ABI.'
: 'This looks like a canonical GRU asset, so the next meaningful checks are reserve, governance, and bridge activation beyond the token interface itself.',
profile.x402Ready
? 'This contract appears ready for x402-style payment flows because the explorer can see the required signature and domain surfaces.'
: 'This contract does not currently look x402-ready from the live explorer surface; verify EIP-712, ERC-5267, and permit or authorization flow exposure before using it as a payment rail.',
profile.forwardCanonical === true
? 'This version is marked forward-canonical, so it should be treated as the preferred successor surface even if older liquidity or transport versions still coexist.'
? 'This version is marked forward-canonical, so it should be treated as the preferred successor surface even if older liquidity or bridge versions still coexist.'
: profile.forwardCanonical === false
? 'This version is not forward-canonical, which usually means it is legacy, staged, or transport-only relative to the intended primary canonical surface.'
: 'Forward-canonical posture is not directly detectable on this contract, so rely on the transport overlay and deployment records before making promotion assumptions.',
? 'This version is not forward-canonical, which usually means it is legacy, staged, or bridge-only relative to the intended primary canonical surface.'
: 'Forward-canonical posture is not directly detectable on this contract, so rely on the bridge overlay and deployment records before making promotion assumptions.',
profile.legacyAliasSupport
? 'Legacy alias support is exposed, which is useful during version cutovers and explorer/search reconciliation.'
: 'Legacy alias support is not visible from the current explorer contract surface, so name/version migration may need registry or deployment-record cross-checks.',
@@ -78,9 +93,9 @@ export default function GruStandardsCard({
<DetailRow label="Profile">
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
<EntityBadge label={profile.profileId} tone="info" className="normal-case tracking-normal" />
<EntityBadge label={formatProfileLabel(profile.profileId)} tone="info" className="normal-case tracking-normal" />
<EntityBadge
label={profile.wrappedTransport ? 'wrapped transport' : 'canonical GRU'}
label={profile.wrappedTransport ? 'cW public-network' : 'canonical GRU'}
tone={profile.wrappedTransport ? 'warning' : 'success'}
/>
</div>
@@ -94,14 +109,14 @@ export default function GruStandardsCard({
{profile.standards.map((standard) => (
<EntityBadge
key={standard.id}
label={standard.detected ? `${standard.id} detected` : `${standard.id} missing`}
label={standard.detected ? `${formatStandardLabel(standard.id)} detected` : `${formatStandardLabel(standard.id)} missing`}
tone={standard.detected ? 'success' : 'warning'}
className="normal-case tracking-normal"
/>
))}
</DetailRow>
<DetailRow label="Transport Posture">
<DetailRow label="Bridge Posture">
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
<EntityBadge
@@ -134,8 +149,8 @@ export default function GruStandardsCard({
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Settlement posture</div>
<div className="mt-2 text-gray-900 dark:text-white">
{profile.wrappedTransport
? 'This contract presents itself like a wrapped public-transport asset instead of the canonical Chain 138 money surface.'
: 'This contract presents itself like the canonical Chain 138 GRU money surface instead of a wrapped transport mirror.'}
? 'This contract presents itself like a cW public-network representation instead of the canonical Chain 138 GRU surface.'
: 'This contract presents itself like the canonical Chain 138 GRU surface instead of a cW public-network representation.'}
</div>
</div>
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 text-sm dark:border-gray-700 dark:bg-gray-900/40">
@@ -150,7 +165,7 @@ export default function GruStandardsCard({
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Version posture</div>
<div className="mt-2 text-gray-900 dark:text-white">
{profile.activeVersion || profile.forwardVersion
? `Active liquidity/transport version: ${profile.activeVersion || 'unknown'}; preferred forward version: ${profile.forwardVersion || 'unknown'}.`
? `Active liquidity/bridge version: ${profile.activeVersion || 'unknown'}; preferred forward version: ${profile.forwardVersion || 'unknown'}.`
: 'No explicit active-versus-forward version posture is available from the local GRU catalog yet.'}
</div>
</div>
@@ -163,7 +178,7 @@ export default function GruStandardsCard({
{profile.standards.map((standard) => (
<div key={`${standard.id}-explanation`} className="rounded-xl border border-gray-200 bg-gray-50 p-3 text-sm dark:border-gray-700 dark:bg-gray-900/40">
<div className="flex flex-wrap items-center gap-2">
<div className="font-medium text-gray-900 dark:text-white">{standard.id}</div>
<div className="font-medium text-gray-900 dark:text-white">{formatStandardLabel(standard.id)}</div>
<EntityBadge label={standard.detected ? 'detected' : 'missing'} tone={standard.detected ? 'success' : 'warning'} />
</div>
<div className="mt-2 text-gray-600 dark:text-gray-400">
@@ -190,12 +205,12 @@ export default function GruStandardsCard({
<DetailRow label="References">
<div className="space-y-2 text-sm text-gray-600 dark:text-gray-400">
<div><Link href="/docs/gru" className="text-primary-600 hover:underline">Explorer GRU guide</Link></div>
<div>Canonical profile: <code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-800">{profile.profileId}</code></div>
<div>Repo standards matrix: <code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-800">docs/04-configuration/GRU_C_STAR_V2_STANDARDS_MATRIX_AND_IMPLEMENTATION_PLAN.md</code></div>
<div>Machine-readable profile: <code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-800">config/gru-standards-profile.json</code></div>
<div>Transport overlay: <code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-800">config/gru-transport-active.json</code></div>
<div>x402 support note: <code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-800">docs/04-configuration/CHAIN138_X402_TOKEN_SUPPORT.md</code></div>
<div>Chain 138 readiness guide: <code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-800">docs/04-configuration/GRU_V2_CHAIN138_READINESS.md</code></div>
<div>Canonical profile: <code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-800">{formatProfileLabel(profile.profileId)}</code></div>
<div>Standards matrix: <code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-800">GRU C* v2 implementation plan</code></div>
<div>Machine-readable profile: <code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-800">GRU standards profile</code></div>
<div>Public-network overlay: <code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-800">GRU cW representation registry</code></div>
<div>x402 support note: <code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-800">Chain 138 x402 token support</code></div>
<div>Chain 138 readiness guide: <code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-800">GRU v2 Chain 138 readiness</code></div>
</div>
</DetailRow>

View File

@@ -0,0 +1,39 @@
import { formatRelativeAge, formatTimestamp } from '@/utils/format'
function formatSource(source?: string | null): string {
switch (source) {
case 'token-aggregation':
return 'token aggregation API'
case 'blockscout':
return 'Blockscout index'
case 'derived':
return 'derived from indexed supply and price inputs'
case 'mission-control':
return 'mission-control liquidity inventory'
default:
return source || 'source unavailable'
}
}
export default function MarketEvidenceNote({
source = 'token-aggregation',
lastUpdated,
method = 'DEX route and pool aggregation; visible liquidity only where indexed.',
compact = false,
}: {
source?: string | null
lastUpdated?: string | null
method?: string
compact?: boolean
}) {
const freshness = lastUpdated ? `${formatRelativeAge(lastUpdated)} (${formatTimestamp(lastUpdated)})` : 'timestamp unavailable'
const text = compact
? `Updated ${freshness} · ${formatSource(source)}`
: `Source: ${formatSource(source)}. Updated: ${freshness}. Method: ${method}`
return (
<p className={`${compact ? 'mt-1' : 'mt-3'} text-xs leading-5 text-gray-500 dark:text-gray-400`}>
{text}
</p>
)
}

View File

@@ -3,7 +3,7 @@
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import { type ReactNode, useEffect, useId, useMemo, useRef, useState } from 'react'
import { accessApi, type WalletAccessSession } from '@/services/api/access'
import { accessApi, institutionalTierLabels, type WalletAccessSession } from '@/services/api/access'
import BrandLockup from './BrandLockup'
import HeaderCommandPalette, { type HeaderCommandItem } from './HeaderCommandPalette'
import { useUiMode } from './UiModeContext'
@@ -310,6 +310,9 @@ function SearchControl({
}
function getAccessTier(walletSession: WalletAccessSession) {
if (walletSession.institutionalTier) {
return institutionalTierLabels[walletSession.institutionalTier] ?? walletSession.institutionalTier
}
const permissions = walletSession.permissions || []
if (permissions.some((permission) => permission.startsWith('operator.'))) {
return 'Operator Tier'
@@ -326,10 +329,11 @@ function getAccessTier(walletSession: WalletAccessSession) {
function getSessionSummary(walletSession: WalletAccessSession) {
const permissionCount = walletSession.permissions?.length || 0
const tierLabel = getAccessTier(walletSession)
const institutionSuffix = walletSession.institutionName ? ` (${walletSession.institutionName})` : ''
if (permissionCount > 0) {
return `${tierLabel} · ${permissionCount} permission${permissionCount === 1 ? '' : 's'}`
return `${tierLabel}${institutionSuffix} · ${permissionCount} permission${permissionCount === 1 ? '' : 's'}`
}
return `${tierLabel} · Explorer access active`
return `${tierLabel}${institutionSuffix} · Explorer access active`
}
function UiModeToggle({ mobile = false }: { mobile?: boolean }) {
@@ -360,6 +364,7 @@ function UiModeToggle({ mobile = false }: { mobile?: boolean }) {
function AccountButton({
walletSession,
connectingWallet,
connectError,
onConnect,
onCopyAddress,
onSwitchWallet,
@@ -367,6 +372,7 @@ function AccountButton({
}: {
walletSession: WalletAccessSession | null
connectingWallet: boolean
connectError?: string | null
onConnect: () => void
onCopyAddress: () => void
onSwitchWallet: () => void
@@ -381,7 +387,7 @@ function AccountButton({
},
{
href: '/wallet',
label: 'Settings',
label: 'Wallet tools',
description: 'Review network, token-list, and wallet configuration guidance.',
},
{
@@ -403,14 +409,21 @@ function AccountButton({
if (!walletSession) {
return (
<button
type="button"
onClick={onConnect}
className="inline-flex items-center gap-2 rounded-2xl bg-gray-950 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-black focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 dark:bg-white dark:text-gray-950 dark:hover:bg-gray-100 dark:focus-visible:ring-offset-gray-900"
>
<span className="inline-flex h-2.5 w-2.5 rounded-full bg-emerald-400" aria-hidden />
<span>{connectingWallet ? 'Connecting…' : 'Connect Wallet'}</span>
</button>
<div className="flex flex-col items-end gap-1">
<button
type="button"
onClick={onConnect}
className="inline-flex items-center gap-2 rounded-2xl bg-gray-950 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-black focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 dark:bg-white dark:text-gray-950 dark:hover:bg-gray-100 dark:focus-visible:ring-offset-gray-900"
>
<span className="inline-flex h-2.5 w-2.5 rounded-full bg-emerald-400" aria-hidden />
<span>{connectingWallet ? 'Connecting…' : 'Connect Wallet'}</span>
</button>
{connectError ? (
<p role="alert" className="max-w-xs text-right text-xs text-red-600 dark:text-red-400">
{connectError}
</p>
) : null}
</div>
)
}
@@ -465,6 +478,7 @@ export default function Navbar() {
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false)
const [walletSession, setWalletSession] = useState<WalletAccessSession | null>(null)
const [connectingWallet, setConnectingWallet] = useState(false)
const [walletConnectError, setWalletConnectError] = useState<string | null>(null)
const mobilePanelId = useId()
const isExploreActive =
@@ -524,13 +538,14 @@ export default function Navbar() {
const handleConnectWallet = async () => {
try {
setConnectingWallet(true)
setWalletConnectError(null)
await accessApi.connectWalletSession()
router.push('/access')
setMobileMenuOpen(false)
router.push('/wallet')
} catch (error) {
console.error('Wallet connect failed', error)
router.push('/access')
setMobileMenuOpen(false)
const message = error instanceof Error ? error.message : 'Wallet connection failed.'
setWalletConnectError(message)
} finally {
setConnectingWallet(false)
}
@@ -584,13 +599,13 @@ export default function Navbar() {
)
const operationsItems: MenuItem[] = useMemo(
() => [
{ href: '/operations', label: 'Operations Hub', description: 'Open the consolidated operator surface for live support workflows.' },
{ href: '/bridge', label: 'Bridge Monitoring', description: 'Inspect relay lanes, queue posture, and bridge trace tooling.' },
{ href: '/operations', label: 'Operations hub', description: 'Open the consolidated operator surface for live support workflows.' },
{ href: '/bridge', label: 'Bridge', description: 'Inspect relay lanes, queue posture, and bridge trace tooling.' },
{ href: '/routes', label: 'Routes', description: 'Review live route coverage, same-chain lanes, and bridge paths.' },
{ href: '/liquidity', label: 'Liquidity', description: 'Check planner-backed route access and live liquidity posture.' },
{ href: '/system', label: 'System', description: 'Inspect topology, RPC capability, and public integration inventory.' },
{ href: '/operator', label: 'Operator Surface', description: 'Open planner, route, and relay shortcuts in one public page.' },
{ href: '/weth', label: 'WETH References', description: 'Review wrapped-asset references and bridge-oriented WETH context.' },
{ href: '/operator', label: 'Operator', description: 'Open planner, route, and relay shortcuts in one public page.' },
{ href: '/weth', label: 'WETH', description: 'Review wrapped-asset references and bridge-oriented WETH context.' },
{ href: '/chain138-command-center.html', label: 'Command Center', description: 'Open the visual command-center reference.', external: true },
],
[],
@@ -699,12 +714,12 @@ export default function Navbar() {
<>
<header className="sticky top-0 z-40 border-b border-gray-200/90 bg-white/95 backdrop-blur supports-[backdrop-filter]:bg-white/88 dark:border-gray-800 dark:bg-gray-950/92">
<div className="container mx-auto px-4">
<div className="flex min-h-[76px] items-center gap-4 lg:min-h-[84px]">
<div className="flex min-h-[60px] items-center gap-3 lg:min-h-[64px]">
<Link
href="/"
className="group inline-flex min-w-0 items-center gap-3 rounded-2xl py-2 pr-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-950"
className="group inline-flex min-w-0 items-center gap-2 rounded-lg py-1.5 pr-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-950"
onClick={() => setMobileMenuOpen(false)}
aria-label="Go to SolaceScan home"
aria-label="Go to DBIS Explorer home"
>
<BrandLockup />
</Link>
@@ -723,12 +738,13 @@ export default function Navbar() {
</div>
</nav>
<div className="ml-auto hidden items-center gap-3 lg:flex">
<div className="ml-auto hidden items-center gap-3 xl:flex">
<SearchControl active={isSearchActive} onSelect={() => setCommandPaletteOpen(true)} />
<UiModeToggle />
<AccountButton
walletSession={walletSession}
connectingWallet={connectingWallet}
connectError={walletConnectError}
onConnect={() => void handleConnectWallet()}
onCopyAddress={() => void handleCopyAddress()}
onSwitchWallet={() => void handleSwitchWallet()}
@@ -736,7 +752,7 @@ export default function Navbar() {
/>
</div>
<div className="ml-auto flex items-center gap-2 lg:hidden">
<div className="ml-auto flex items-center gap-2 xl:hidden">
{walletSession ? (
<Link
href="/access"
@@ -789,7 +805,7 @@ export default function Navbar() {
{mobileMenuOpen ? (
<div
id={mobilePanelId}
className="border-t border-gray-200 py-4 dark:border-gray-800 lg:hidden"
className="border-t border-gray-200 py-4 dark:border-gray-800 xl:hidden"
>
<div className="flex flex-col gap-4">
<SearchControl
@@ -802,6 +818,12 @@ export default function Navbar() {
/>
<UiModeToggle mobile />
{walletConnectError ? (
<p role="alert" className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-300">
{walletConnectError}
</p>
) : null}
<div className="grid gap-4">
<div>
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">

View File

@@ -17,29 +17,33 @@ export default function PageIntro({
actions?: PageIntroAction[]
}) {
return (
<div className="mb-6 rounded-3xl border border-gray-200 bg-white/90 p-5 shadow-sm dark:border-gray-700 dark:bg-gray-800/80 sm:mb-8 sm:p-6">
{eyebrow ? (
<div className="mb-3 inline-flex rounded-full border border-sky-200 bg-sky-50 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-sky-700 dark:border-sky-900/60 dark:bg-sky-950/30 dark:text-sky-300">
{eyebrow}
<section className="mb-5 border-b border-gray-200 pb-5 dark:border-gray-800 sm:mb-6 sm:pb-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="min-w-0">
{eyebrow ? (
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-primary-700 dark:text-primary-300">
{eyebrow}
</div>
) : null}
<h1 className="text-2xl font-semibold tracking-normal text-gray-950 dark:text-white sm:text-3xl">{title}</h1>
<p className="mt-2 max-w-3xl text-sm leading-6 text-gray-600 dark:text-gray-400">
{description}
</p>
</div>
) : null}
<h1 className="text-3xl font-bold text-gray-900 dark:text-white sm:text-4xl">{title}</h1>
<p className="mt-3 max-w-4xl text-sm leading-7 text-gray-600 dark:text-gray-400 sm:text-base">
{description}
</p>
{actions.length > 0 ? (
<div className="mt-5 flex flex-wrap gap-3">
<div className="flex flex-wrap gap-2 lg:justify-end">
{actions.map((action) => (
<Link
key={`${action.href}-${action.label}`}
href={action.href}
className="rounded-full border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-600 dark:text-gray-200 dark:hover:text-primary-300"
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200 dark:hover:text-primary-300"
>
{action.label}
</Link>
))}
</div>
) : null}
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,60 @@
interface PaginationControlsProps {
page: number
pageCount: number
onPageChange: (page: number) => void
label?: string
className?: string
}
export default function PaginationControls({
page,
pageCount,
onPageChange,
label = 'Rows',
className = '',
}: PaginationControlsProps) {
if (pageCount <= 1) return null
const pages = Array.from({ length: pageCount }, (_, index) => index + 1)
return (
<div className={`mt-4 flex flex-wrap items-center justify-between gap-3 ${className}`}>
<div className="text-sm text-gray-600 dark:text-gray-400">
{label}: page {page} of {pageCount}
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => onPageChange(Math.max(1, page - 1))}
disabled={page <= 1}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:text-gray-300 dark:hover:text-primary-300"
>
Previous
</button>
{pages.map((candidate) => (
<button
key={candidate}
type="button"
onClick={() => onPageChange(candidate)}
aria-current={candidate === page ? 'page' : undefined}
className={
candidate === page
? 'rounded-lg bg-primary-600 px-3 py-2 text-sm font-semibold text-white'
: 'rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-700 dark:text-gray-300 dark:hover:text-primary-300'
}
>
{candidate}
</button>
))}
<button
type="button"
onClick={() => onPageChange(Math.min(pageCount, page + 1))}
disabled={page >= pageCount}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:text-gray-300 dark:hover:text-primary-300"
>
Next
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,45 @@
export interface SectionTab<T extends string> {
id: T
label: string
count?: number
}
interface SectionTabsProps<T extends string> {
tabs: SectionTab<T>[]
activeTab: T
onChange: (tab: T) => void
className?: string
}
export default function SectionTabs<T extends string>({
tabs,
activeTab,
onChange,
className = '',
}: SectionTabsProps<T>) {
return (
<div className={`sticky top-0 z-20 border-b border-gray-200 bg-white/95 py-3 backdrop-blur dark:border-gray-800 dark:bg-gray-950/95 ${className}`}>
<div className="flex gap-2 overflow-x-auto">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => onChange(tab.id)}
className={
activeTab === tab.id
? 'whitespace-nowrap rounded-lg bg-primary-600 px-3 py-2 text-sm font-semibold text-white'
: 'whitespace-nowrap rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-700 dark:text-gray-300 dark:hover:text-primary-300'
}
>
{tab.label}
{typeof tab.count === 'number' ? (
<span className={activeTab === tab.id ? 'ml-2 text-primary-100' : 'ml-2 text-gray-500 dark:text-gray-400'}>
{tab.count.toLocaleString()}
</span>
) : null}
</button>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,13 @@
import { TOKEN_LIST_SURFACE_LABELS, type TokenListSurface } from '@/services/api/tokenListSurfaces'
interface TokenListSurfaceNoteProps {
surface?: TokenListSurface
className?: string
}
export default function TokenListSurfaceNote({
surface = 'extended',
className = 'text-sm text-gray-600 dark:text-gray-400',
}: TokenListSurfaceNoteProps) {
return <p className={className}>{TOKEN_LIST_SURFACE_LABELS[surface]}</p>
}

View File

@@ -0,0 +1,217 @@
import { useEffect, useMemo, useState } from 'react'
import { Card } from '@/libs/frontend-ui-primitives'
import { DetailRow } from '@/components/common/DetailRow'
import EntityBadge from '@/components/common/EntityBadge'
import type { ContractProfile } from '@/services/api/contracts'
import { fetchEip712DomainDecoded, type DecodedEip712Domain } from '@/services/api/eip712Domain'
function hasMethod(profile: ContractProfile | null | undefined, name: string): boolean {
if (!profile) return false
const all = [...(profile.read_methods || []), ...(profile.write_methods || [])]
return all.some((m) => m.name === name)
}
const ERC5267_EXPLANATION =
'ERC-5267 defines eip712Domain() so wallets and relayers can discover the EIP-712 signing domain without guessing types or replay parameters.'
export default function TokenSigningSurfaceCard({
address,
contractProfile,
}: {
address: string
contractProfile: ContractProfile | null
}) {
const [domain, setDomain] = useState<DecodedEip712Domain | null>(null)
const [domainError, setDomainError] = useState<string | null>(null)
const abiHasEip712Domain = hasMethod(contractProfile, 'eip712Domain')
useEffect(() => {
if (!abiHasEip712Domain) {
setDomain(null)
setDomainError(null)
return
}
let cancelled = false
setDomainError(null)
void (async () => {
try {
const decoded = await fetchEip712DomainDecoded(address)
if (!cancelled) {
setDomain(decoded)
if (!decoded) setDomainError('eip712Domain() is present in the ABI but the live call did not return decodable data (proxy, revert, or RPC).')
}
} catch (e) {
if (!cancelled) {
setDomain(null)
setDomainError(e instanceof Error ? e.message : 'Failed to read eip712Domain.')
}
}
})()
return () => {
cancelled = true
}
}, [address, abiHasEip712Domain])
const standards = useMemo(
() => [
{
id: 'ERC-20',
detected:
hasMethod(contractProfile, 'name') ||
hasMethod(contractProfile, 'symbol') ||
hasMethod(contractProfile, 'decimals') ||
hasMethod(contractProfile, 'totalSupply'),
note: 'Standard fungible token interface expected by explorers and wallets.',
},
{
id: 'EIP-712',
detected: hasMethod(contractProfile, 'DOMAIN_SEPARATOR') || abiHasEip712Domain,
note: 'Typed structured data hashing for signatures.',
},
{
id: 'ERC-2612',
detected: hasMethod(contractProfile, 'permit') || hasMethod(contractProfile, 'nonces'),
note: 'Permit-style allowance via signature.',
},
{
id: 'ERC-3009',
detected:
hasMethod(contractProfile, 'authorizationState') ||
hasMethod(contractProfile, 'transferWithAuthorization') ||
hasMethod(contractProfile, 'receiveWithAuthorization'),
note: 'Transfer authorization without prior allowance.',
},
{
id: 'ERC-5267',
detected: abiHasEip712Domain,
note: ERC5267_EXPLANATION,
},
],
[contractProfile, abiHasEip712Domain],
)
const verificationMeta = useMemo(() => {
if (!contractProfile) return []
const rows: { label: string; value: string }[] = []
if (contractProfile.contract_name) rows.push({ label: 'Verified name', value: contractProfile.contract_name })
if (contractProfile.compiler_version) rows.push({ label: 'Compiler', value: contractProfile.compiler_version })
if (contractProfile.license_type) rows.push({ label: 'License', value: contractProfile.license_type })
if (contractProfile.evm_version) rows.push({ label: 'EVM version', value: contractProfile.evm_version })
if (contractProfile.optimization_enabled != null) {
rows.push({
label: 'Optimization',
value: `${contractProfile.optimization_enabled ? 'On' : 'Off'}${contractProfile.optimization_runs != null ? ` · ${contractProfile.optimization_runs} runs` : ''}`,
})
}
if (contractProfile.source_status_text) rows.push({ label: 'Source status', value: contractProfile.source_status_text })
return rows
}, [contractProfile])
if (!contractProfile) {
return (
<Card title="Signing surface & verification metadata">
<p className="text-sm text-gray-600 dark:text-gray-400">
Contract ABI and verification metadata were not available. Open the contract address page after Blockscout indexes this token, or verify the contract on the explorer.
</p>
</Card>
)
}
return (
<Card title="Signing surface & verification metadata">
<dl className="space-y-4">
<DetailRow label="ABI coverage" valueClassName="flex flex-wrap gap-2">
<EntityBadge label={contractProfile.abi_available ? 'ABI available' : 'ABI unavailable'} tone={contractProfile.abi_available ? 'success' : 'warning'} />
<EntityBadge label={contractProfile.source_verified ? 'Source verified' : 'Source not verified'} tone={contractProfile.source_verified ? 'success' : 'warning'} />
</DetailRow>
<DetailRow label="ERC-5267 (EIP-712 domain introspection)" valueClassName="space-y-3">
<div className="flex flex-wrap gap-2">
<EntityBadge
label={abiHasEip712Domain ? 'eip712Domain() in ABI' : 'eip712Domain() not in ABI'}
tone={abiHasEip712Domain ? 'success' : 'warning'}
/>
{domain ? <EntityBadge label="Live domain decoded" tone="success" /> : null}
</div>
<p className="text-sm text-gray-600 dark:text-gray-400">{ERC5267_EXPLANATION}</p>
{domain ? (
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 text-sm dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Domain fields</div>
<dl className="mt-2 space-y-1.5 text-gray-900 dark:text-white">
<div><span className="text-gray-500 dark:text-gray-400">fields </span>{domain.fields}</div>
<div><span className="text-gray-500 dark:text-gray-400">name </span>{domain.name || '—'}</div>
<div><span className="text-gray-500 dark:text-gray-400">version </span>{domain.version || '—'}</div>
<div><span className="text-gray-500 dark:text-gray-400">chainId </span>{domain.chainId}</div>
<div className="break-all">
<span className="text-gray-500 dark:text-gray-400">verifyingContract </span>
{domain.verifyingContract}
</div>
<div className="break-all">
<span className="text-gray-500 dark:text-gray-400">salt </span>
{domain.salt}
</div>
<div className="break-all">
<span className="text-gray-500 dark:text-gray-400">extensions </span>
{domain.extensionsSummary}
</div>
</dl>
</div>
</div>
) : abiHasEip712Domain && domainError ? (
<p className="text-sm text-amber-700 dark:text-amber-300">{domainError}</p>
) : !abiHasEip712Domain ? (
<p className="text-sm text-gray-600 dark:text-gray-400">
This contracts verified ABI does not expose eip712Domain(). ERC-5267 introspection is unavailable from the explorer surface until the implementation adds it.
</p>
) : null}
</DetailRow>
<DetailRow label="Related interfaces" valueClassName="flex flex-wrap gap-2">
{standards
.filter((s) => s.id !== 'ERC-5267')
.map((s) => (
<EntityBadge
key={s.id}
label={`${s.id} ${s.detected ? 'detected' : 'not detected'}`}
tone={s.detected ? 'success' : 'warning'}
className="normal-case tracking-normal"
/>
))}
</DetailRow>
{verificationMeta.length > 0 ? (
<DetailRow label="Verification metadata">
<div className="grid gap-3 sm:grid-cols-2">
{verificationMeta.map((row) => (
<div key={row.label} className="rounded-xl border border-gray-200 bg-gray-50 p-3 text-sm dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{row.label}</div>
<div className="mt-2 break-words text-gray-900 dark:text-white">{row.value}</div>
</div>
))}
</div>
</DetailRow>
) : (
<DetailRow label="Verification metadata">
<span className="text-sm text-gray-600 dark:text-gray-400">No compiler or naming metadata was returned with this contract record.</span>
</DetailRow>
)}
<DetailRow label="Interpretation">
<div className="space-y-3">
{standards.map((s) => (
<div key={s.id} className="rounded-xl border border-gray-200 bg-gray-50 p-3 text-sm dark:border-gray-700 dark:bg-gray-900/40">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-gray-900 dark:text-white">{s.id}</span>
<EntityBadge label={s.detected ? 'detected' : 'not detected'} tone={s.detected ? 'success' : 'warning'} />
</div>
<p className="mt-2 text-gray-600 dark:text-gray-400">{s.note}</p>
</div>
))}
</div>
</DetailRow>
</dl>
</Card>
)
}

View File

@@ -16,6 +16,11 @@ import ActivityContextPanel from '@/components/common/ActivityContextPanel'
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
import SubsystemPosturePanel from '@/components/common/SubsystemPosturePanel'
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
import { bridgeRoutesApi, normalizeBridgeRouteEntries, type BridgeRoutesResponse } from '@/services/api/bridgeRoutes'
import { createVisibilityAwarePoller } from '@/utils/visibilityRefresh'
import { HOME_DASHBOARD_REFRESH_MS } from '@/utils/featuredTokens'
import OperationsSurfaceNav from './OperationsSurfaceNav'
import OperationsActionGrid from './OperationsActionGrid'
type FeedState = 'connecting' | 'live' | 'fallback'
@@ -146,6 +151,7 @@ export default function BridgeMonitoringPage({
}) {
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
const [stats, setStats] = useState<ExplorerStats | null>(initialStats)
const [bridgeRoutes, setBridgeRoutes] = useState<BridgeRoutesResponse | null>(null)
const [feedState, setFeedState] = useState<FeedState>(initialBridgeStatus ? 'fallback' : 'connecting')
const page = explorerFeaturePages.bridge
@@ -196,6 +202,49 @@ export default function BridgeMonitoringPage({
}
}, [])
useEffect(() => {
let cancelled = false
bridgeRoutesApi.getRoutesSafe().then(({ ok, data }) => {
if (!cancelled && ok) {
setBridgeRoutes(data)
}
})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
if (feedState !== 'fallback') return
let cancelled = false
const refreshSnapshot = async () => {
try {
const snapshot = await missionControlApi.getBridgeStatus()
if (!cancelled) {
setBridgeStatus(snapshot)
}
} catch (error) {
if (!cancelled && process.env.NODE_ENV !== 'production') {
console.warn('Failed to refresh bridge monitoring snapshot:', error)
}
}
}
return createVisibilityAwarePoller({
intervalMs: HOME_DASHBOARD_REFRESH_MS,
task: refreshSnapshot,
})
}, [feedState])
const routeEntries = useMemo(
() => normalizeBridgeRouteEntries(bridgeRoutes?.routes),
[bridgeRoutes?.routes],
)
const activityContext = useMemo(
() =>
summarizeChainActivity({
@@ -280,6 +329,8 @@ export default function BridgeMonitoringPage({
</Card>
) : null}
<OperationsSurfaceNav />
<div className="mb-6">
<ActivityContextPanel context={activityContext} title="Bridge Freshness Context" />
<FreshnessTrustNote
@@ -407,27 +458,46 @@ export default function BridgeMonitoringPage({
))}
</div>
<div className="grid gap-4 lg:grid-cols-2">
{page.actions.map((action) => (
<Card key={`${action.title}-${action.href}`} className="border border-gray-200 dark:border-gray-700">
<div className="flex h-full flex-col">
<div className="text-base font-semibold text-gray-900 dark:text-white">
{action.title}
</div>
<p className="mt-2 flex-1 text-sm leading-6 text-gray-600 dark:text-gray-400">
{action.description}
</p>
<div className="mt-4">
<ActionLink
href={action.href}
label={action.label}
external={'external' in action ? action.external : undefined}
/>
</div>
</div>
</Card>
))}
</div>
{routeEntries.length > 0 ? (
<Card title="CCIP route catalog" className="mb-8">
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
Destination bridge contracts from{' '}
<code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-900">
/token-aggregation/api/v1/bridge/routes
</code>
{bridgeRoutes?.source ? (
<>
{' '}
(source: {bridgeRoutes.source}
{bridgeRoutes.lastModified ? ` · updated ${relativeAge(bridgeRoutes.lastModified)}` : ''})
</>
) : null}
.
</p>
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-500 dark:border-gray-700 dark:text-gray-400">
<th className="py-2 pr-4">Bridge</th>
<th className="py-2 pr-4">Destination</th>
<th className="py-2">Contract</th>
</tr>
</thead>
<tbody>
{routeEntries.map((entry) => (
<tr key={`${entry.bridge}-${entry.destination}`} className="border-b border-gray-100 last:border-0 dark:border-gray-800">
<td className="py-2 pr-4 font-medium text-gray-900 dark:text-white">{entry.bridge}</td>
<td className="py-2 pr-4 text-gray-700 dark:text-gray-300">{entry.destination}</td>
<td className="py-2 font-mono text-xs text-gray-600 dark:text-gray-400">{entry.address}</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
) : null}
<OperationsActionGrid actions={page.actions} />
</div>
)
}

View File

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

View File

@@ -0,0 +1,47 @@
import Link from 'next/link'
import { Card } from '@/libs/frontend-ui-primitives'
export const CONTRACT_VERIFICATION_GUIDE_URL =
'https://gitea.d-bis.org/d-bis/proxmox/src/branch/master/docs/08-monitoring/BLOCKSCOUT_VERIFICATION_GUIDE.md'
export const FORGE_VERIFY_COMMAND =
'source scripts/lib/load-project-env.sh && ./scripts/verify/run-contract-verification-with-proxy.sh'
interface ContractVerificationCalloutProps {
address: string
verified: boolean
}
export default function ContractVerificationCallout({ address, verified }: ContractVerificationCalloutProps) {
if (verified) {
return null
}
return (
<Card title="Verify & Publish Contract" className="mb-6">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
This contract is not verified on the public explorer yet. Verified source improves read/write tooling,
ABI decoding, and auditability for{' '}
<span className="font-mono text-xs">{address}</span>.
</p>
<ul className="mt-4 list-disc space-y-2 pl-5 text-sm text-gray-700 dark:text-gray-300">
<li>
<strong>Forge batch (recommended):</strong>{' '}
<code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-900">{FORGE_VERIFY_COMMAND}</code>
</li>
<li>
<strong>Operator guide:</strong>{' '}
<Link href={CONTRACT_VERIFICATION_GUIDE_URL} className="text-primary-600 hover:underline" target="_blank" rel="noopener noreferrer">
Blockscout verification guide
</Link>
</li>
<li>
<strong>Explorer contract tab:</strong>{' '}
<Link href={`/addresses/${address}`} className="text-primary-600 hover:underline">
Open this address and review the Contract tab
</Link>
</li>
</ul>
</Card>
)
}

View File

@@ -3,7 +3,8 @@
import { useEffect, useMemo, useState } from 'react'
import Link from 'next/link'
import { Card } from '@/libs/frontend-ui-primitives'
import { configApi, type TokenListResponse } from '@/services/api/config'
import { type TokenListResponse } from '@/services/api/config'
import { tokensApi } from '@/services/api/tokens'
import {
aggregateLiquidityPools,
featuredLiquiditySymbols,
@@ -20,7 +21,10 @@ import { statsApi, type ExplorerStats } from '@/services/api/stats'
import { summarizeChainActivity } from '@/utils/activityContext'
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
import MarketEvidenceNote from '@/components/common/MarketEvidenceNote'
import SubsystemPosturePanel from '@/components/common/SubsystemPosturePanel'
import TokenListSurfaceNote from '@/components/common/TokenListSurfaceNote'
import OperationsSurfaceNav from './OperationsSurfaceNav'
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
import {
formatCurrency,
@@ -97,7 +101,7 @@ export default function LiquidityOperationsPage({
const load = async () => {
const [tokenListResult, routeMatrixResult, plannerCapabilitiesResult, planResult, statsResult, bridgeResult] =
await Promise.allSettled([
configApi.getTokenList(),
tokensApi.listForSurface('extended', 138).then(({ ok, data }) => ({ tokens: ok ? data : [] })),
routesApi.getRouteMatrix(),
plannerApi.getCapabilities(),
plannerApi.getInternalExecutionPlan(),
@@ -197,6 +201,11 @@ export default function LiquidityOperationsPage({
}),
[bridgeStatus, stats],
)
const liquidityInventoryUpdatedAt =
stats?.sampling?.stats_generated_at ||
stats?.freshness?.chain_head?.timestamp ||
routeMatrix?.generatedAt ||
routeMatrix?.updated
const insightLines = useMemo(
() => [
@@ -233,6 +242,12 @@ export default function LiquidityOperationsPage({
href: `/explorer-api/v1/mission-control/liquidity/token/${featuredTokens[0]?.address || '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22'}/pools`,
notes: 'Cached public pool inventory for a specific Chain 138 token.',
},
{
name: 'External indexer readiness',
method: 'GET',
href: `/api/v1/report/external-indexer-readiness?chainId=138`,
notes: 'One JSON posture for DefiLlama, CoinGecko, CoinMarketCap, and Dexscreener readiness.',
},
]
const copyEndpoint = async (endpoint: EndpointCard) => {
@@ -261,8 +276,11 @@ export default function LiquidityOperationsPage({
public route matrix, planner capabilities, and mission-control token pool inventory together
so integrators can inspect what Chain 138 is actually serving right now.
</p>
<TokenListSurfaceNote className="mt-3 text-sm text-gray-600 dark:text-gray-400" />
</div>
<OperationsSurfaceNav />
{loadingError ? (
<Card className="mb-6 border border-red-200 bg-red-50/70 dark:border-red-900/50 dark:bg-red-950/20">
<p className="text-sm leading-6 text-red-900 dark:text-red-100">{loadingError}</p>
@@ -318,6 +336,12 @@ export default function LiquidityOperationsPage({
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
{formatNumber(dexCount)} DEX families in the current discovered pools.
</div>
<MarketEvidenceNote
source="mission-control"
lastUpdated={liquidityInventoryUpdatedAt}
method="Route matrix, provider capabilities, and mission-control pool inventory are reconciled for visible public liquidity only."
compact
/>
</Card>
<Card>
<div className="text-sm text-gray-500 dark:text-gray-400">Fallback posture</div>
@@ -354,6 +378,12 @@ export default function LiquidityOperationsPage({
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
Seen from {pool.sourceSymbols.join(', ')}
</div>
<MarketEvidenceNote
source="mission-control"
lastUpdated={liquidityInventoryUpdatedAt}
method="Pool TVL is the visible mission-control value for discovered route-backed liquidity."
compact
/>
</div>
))}
{aggregatedPools.length === 0 ? (

View File

@@ -0,0 +1,73 @@
import Link from 'next/link'
import { Card } from '@/libs/frontend-ui-primitives'
import type { ExplorerFeatureAction } from '@/data/explorerOperations'
export function OperationsActionLink({ action }: { action: ExplorerFeatureAction }) {
const className = 'inline-flex items-center text-sm font-semibold text-primary-600 hover:underline'
const label = `${action.label} ->`
if (action.external) {
return (
<a href={action.href} className={className} target="_blank" rel="noopener noreferrer">
{label}
</a>
)
}
return (
<Link href={action.href} className={className}>
{label}
</Link>
)
}
function ActionCard({ action }: { action: ExplorerFeatureAction }) {
return (
<Card className="border border-gray-200 dark:border-gray-700">
<div className="flex h-full flex-col">
<div className="text-base font-semibold text-gray-900 dark:text-white">{action.title}</div>
<p className="mt-2 flex-1 text-sm leading-6 text-gray-600 dark:text-gray-400">{action.description}</p>
<div className="mt-4">
<OperationsActionLink action={action} />
</div>
</div>
</Card>
)
}
export default function OperationsActionGrid({
actions,
title = 'Quick actions',
}: {
actions: ExplorerFeatureAction[]
title?: string
}) {
if (actions.length === 0) return null
return (
<>
<details className="group mb-6 rounded-2xl border border-gray-200 bg-gray-50/80 dark:border-gray-800 dark:bg-gray-900/40 md:hidden">
<summary className="cursor-pointer list-none px-4 py-3 text-sm font-semibold text-gray-900 dark:text-white [&::-webkit-details-marker]:hidden">
<span className="flex items-center justify-between gap-3">
{title}
<span className="text-xs font-normal uppercase tracking-wide text-gray-500">
{actions.length} links · <span className="group-open:hidden">Show</span>
<span className="hidden group-open:inline">Hide</span>
</span>
</span>
</summary>
<div className="space-y-3 border-t border-gray-200 px-3 py-3 dark:border-gray-800">
{actions.map((action) => (
<ActionCard key={`${action.title}-${action.href}`} action={action} />
))}
</div>
</details>
<div className="hidden gap-4 md:grid lg:grid-cols-2">
{actions.map((action) => (
<ActionCard key={`${action.title}-${action.href}`} action={action} />
))}
</div>
</>
)
}

View File

@@ -3,6 +3,7 @@ import Link from 'next/link'
import { Card } from '@/libs/frontend-ui-primitives'
import { explorerFeaturePages } from '@/data/explorerOperations'
import { configApi, type CapabilitiesResponse, type NetworksConfigResponse, type TokenListResponse } from '@/services/api/config'
import { tokensApi } from '@/services/api/tokens'
import { getMissionControlRelays, missionControlApi, type MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
import { routesApi, type RouteMatrixResponse } from '@/services/api/routes'
import { useUiMode } from '@/components/common/UiModeContext'
@@ -10,6 +11,9 @@ import { summarizeChainActivity } from '@/utils/activityContext'
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
import SubsystemPosturePanel from '@/components/common/SubsystemPosturePanel'
import TokenListSurfaceNote from '@/components/common/TokenListSurfaceNote'
import OperationsSurfaceNav from '@/components/explorer/OperationsSurfaceNav'
import OperationsActionGrid from '@/components/explorer/OperationsActionGrid'
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
import { statsApi, type ExplorerStats } from '@/services/api/stats'
@@ -88,7 +92,7 @@ export default function OperationsHubPage({
missionControlApi.getBridgeStatus(),
routesApi.getRouteMatrix(),
configApi.getNetworks(),
configApi.getTokenList(),
tokensApi.listForSurface('extended', 138).then(({ ok, data }) => ({ tokens: ok ? data : [] })),
configApi.getCapabilities(),
statsApi.get(),
])
@@ -179,6 +183,7 @@ export default function OperationsHubPage({
<p className="text-base leading-7 text-gray-600 dark:text-gray-400 sm:text-lg sm:leading-8">
{page.description}
</p>
<TokenListSurfaceNote className="mt-3 text-sm text-gray-600 dark:text-gray-400" />
</div>
{page.note ? (
@@ -189,6 +194,8 @@ export default function OperationsHubPage({
</Card>
) : null}
<OperationsSurfaceNav />
{loadingError ? (
<Card className="mb-6 border border-red-200 bg-red-50/70 dark:border-red-900/50 dark:bg-red-950/20">
<p className="text-sm leading-6 text-red-900 dark:text-red-100">{loadingError}</p>
@@ -335,27 +342,7 @@ export default function OperationsHubPage({
</Card>
</div>
<div className="grid gap-4 lg:grid-cols-2">
{page.actions.map((action) => (
<Card key={`${action.title}-${action.href}`} className="border border-gray-200 dark:border-gray-700">
<div className="flex h-full flex-col">
<div className="text-base font-semibold text-gray-900 dark:text-white">
{action.title}
</div>
<p className="mt-2 flex-1 text-sm leading-6 text-gray-600 dark:text-gray-400">
{action.description}
</p>
<div className="mt-4">
<ActionLink
href={action.href}
label={action.label}
external={'external' in action ? action.external : undefined}
/>
</div>
</div>
</Card>
))}
</div>
<OperationsActionGrid actions={page.actions} />
</div>
)
}

View File

@@ -1,29 +1,12 @@
import type { ReactNode } from 'react'
import Link from 'next/link'
import { Card } from '@/libs/frontend-ui-primitives'
import type { ExplorerFeatureAction, ExplorerFeaturePage } from '@/data/explorerOperations'
import type { ExplorerFeaturePage } from '@/data/explorerOperations'
import OperationsSurfaceNav from './OperationsSurfaceNav'
import OperationsActionGrid from './OperationsActionGrid'
import OperationsTrackNote from './OperationsTrackNote'
export type StatusTone = 'normal' | 'warning' | 'danger'
function ActionLink({ action }: { action: ExplorerFeatureAction }) {
const className = 'inline-flex items-center text-sm font-semibold text-primary-600 hover:underline'
const label = `${action.label} ->`
if (action.external) {
return (
<a href={action.href} className={className} target="_blank" rel="noopener noreferrer">
{label}
</a>
)
}
return (
<Link href={action.href} className={className}>
{label}
</Link>
)
}
export function relativeAge(isoString?: string): string {
if (!isoString) return 'Unknown'
const parsed = Date.parse(isoString)
@@ -126,23 +109,15 @@ export default function OperationsPageShell({
</Card>
) : null}
{page.accessTrack && page.accessNote ? (
<OperationsTrackNote track={page.accessTrack} note={page.accessNote} />
) : null}
<OperationsSurfaceNav />
{children}
<div className="grid gap-4 lg:grid-cols-2">
{page.actions.map((action) => (
<Card key={`${action.title}-${action.href}`} className="border border-gray-200 dark:border-gray-700">
<div className="flex h-full flex-col">
<div className="text-base font-semibold text-gray-900 dark:text-white">{action.title}</div>
<p className="mt-2 flex-1 text-sm leading-6 text-gray-600 dark:text-gray-400">
{action.description}
</p>
<div className="mt-4">
<ActionLink action={action} />
</div>
</div>
</Card>
))}
</div>
<OperationsActionGrid actions={page.actions} />
</div>
)
}

View File

@@ -0,0 +1,79 @@
'use client'
import Link from 'next/link'
import { useRouter } from 'next/router'
import clsx from 'clsx'
import { explorerOperationsSurfaces } from '@/data/explorerOperations'
function normalizePath(path: string): string {
if (path.length > 1 && path.endsWith('/')) {
return path.slice(0, -1)
}
return path
}
export default function OperationsSurfaceNav({ className }: { className?: string }) {
const router = useRouter()
const currentPath = normalizePath(router.pathname)
return (
<nav aria-label="Operations surfaces" className={clsx('mb-6', className)}>
<details className="group rounded-2xl border border-gray-200 bg-gray-50/80 dark:border-gray-800 dark:bg-gray-900/40 md:hidden">
<summary className="cursor-pointer list-none px-4 py-3 text-sm font-semibold text-gray-900 dark:text-white [&::-webkit-details-marker]:hidden">
<span className="flex items-center justify-between gap-3">
Jump to operations surface
<span className="text-xs font-normal uppercase tracking-wide text-gray-500 group-open:hidden">Show</span>
<span className="hidden text-xs font-normal uppercase tracking-wide text-gray-500 group-open:inline">Hide</span>
</span>
</summary>
<ul className="space-y-1 border-t border-gray-200 px-2 py-2 dark:border-gray-800">
{explorerOperationsSurfaces.map((surface) => {
const active = currentPath === surface.href
return (
<li key={surface.href}>
<Link
href={surface.href}
className={clsx(
'block rounded-xl px-3 py-2 transition',
active
? 'bg-primary-50 text-primary-700 dark:bg-primary-950/40 dark:text-primary-200'
: 'text-gray-700 hover:bg-white dark:text-gray-300 dark:hover:bg-gray-950/60',
)}
>
<div className="text-sm font-semibold">{surface.label}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{surface.description}</div>
</Link>
</li>
)
})}
</ul>
</details>
<div className="hidden md:block">
<div className="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">
Operations surfaces
</div>
<div className="mt-3 flex flex-wrap gap-2">
{explorerOperationsSurfaces.map((surface) => {
const active = currentPath === surface.href
return (
<Link
key={surface.href}
href={surface.href}
title={surface.description}
className={clsx(
'rounded-full border px-3 py-1.5 text-sm font-medium transition',
active
? 'border-primary-500 bg-primary-50 text-primary-700 dark:border-primary-400 dark:bg-primary-950/40 dark:text-primary-200'
: 'border-gray-200 text-gray-700 hover:border-primary-300 hover:text-primary-600 dark:border-gray-700 dark:text-gray-300 dark:hover:border-primary-500 dark:hover:text-primary-300',
)}
>
{surface.label}
</Link>
)
})}
</div>
</div>
</nav>
)
}

View File

@@ -0,0 +1,20 @@
import { Card } from '@/libs/frontend-ui-primitives'
export default function OperationsTrackNote({
track,
note,
className,
}: {
track: number
note: string
className?: string
}) {
return (
<Card className={className ?? 'mb-6 border border-violet-200 bg-violet-50/70 dark:border-violet-900/50 dark:bg-violet-950/20'}>
<div className="text-xs font-semibold uppercase tracking-[0.2em] text-violet-700 dark:text-violet-200">
Track {track} public surface
</div>
<p className="mt-2 text-sm leading-6 text-violet-950 dark:text-violet-100">{note}</p>
</Card>
)
}

View File

@@ -3,7 +3,10 @@
import { useEffect, useMemo, useState } from 'react'
import Link from 'next/link'
import { Card } from '@/libs/frontend-ui-primitives'
import { configApi, type TokenListResponse } from '@/services/api/config'
import { type TokenListResponse } from '@/services/api/config'
import { tokensApi } from '@/services/api/tokens'
import TokenListSurfaceNote from '@/components/common/TokenListSurfaceNote'
import OperationsSurfaceNav from './OperationsSurfaceNav'
import {
aggregateLiquidityPools,
getRouteBackedPoolAddresses,
@@ -28,7 +31,7 @@ export default function PoolsOperationsPage() {
const load = async () => {
const [tokenListResult, routeMatrixResult] = await Promise.allSettled([
configApi.getTokenList(),
tokensApi.listForSurface('extended', 138).then(({ ok, data }) => ({ tokens: ok ? data : [] })),
routesApi.getRouteMatrix(),
])
@@ -100,8 +103,11 @@ export default function PoolsOperationsPage() {
This page now summarizes the live pool inventory discovered through mission-control token
pool endpoints and cross-checks it against the current route matrix.
</p>
<TokenListSurfaceNote className="mt-3 text-sm text-gray-600 dark:text-gray-400" />
</div>
<OperationsSurfaceNav />
{loadingError ? (
<Card className="mb-6 border border-red-200 bg-red-50/70 dark:border-red-900/50 dark:bg-red-950/20">
<p className="text-sm leading-6 text-red-900 dark:text-red-100">{loadingError}</p>

View File

@@ -16,6 +16,8 @@ import ActivityContextPanel from '@/components/common/ActivityContextPanel'
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
import SubsystemPosturePanel from '@/components/common/SubsystemPosturePanel'
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
import OperationsSurfaceNav from './OperationsSurfaceNav'
import OperationsActionGrid from './OperationsActionGrid'
interface RoutesMonitoringPageProps {
initialRouteMatrix?: RouteMatrixResponse | null
@@ -224,6 +226,8 @@ export default function RoutesMonitoringPage({
</Card>
) : null}
<OperationsSurfaceNav />
{loadingError ? (
<Card className="mb-6 border border-red-200 bg-red-50/70 dark:border-red-900/50 dark:bg-red-950/20">
<p className="text-sm leading-6 text-red-900 dark:text-red-100">{loadingError}</p>
@@ -438,27 +442,7 @@ export default function RoutesMonitoringPage({
</Card>
</div>
<div className="grid gap-4 lg:grid-cols-2">
{page.actions.map((action) => (
<Card key={`${action.title}-${action.href}`} className="border border-gray-200 dark:border-gray-700">
<div className="flex h-full flex-col">
<div className="text-base font-semibold text-gray-900 dark:text-white">
{action.title}
</div>
<p className="mt-2 flex-1 text-sm leading-6 text-gray-600 dark:text-gray-400">
{action.description}
</p>
<div className="mt-4">
<ActionLink
href={action.href}
label={action.label}
external={Boolean((action as { external?: boolean }).external)}
/>
</div>
</div>
</Card>
))}
</div>
<OperationsActionGrid actions={page.actions} />
</div>
)
}

View File

@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'
import { Card } from '@/libs/frontend-ui-primitives'
import { explorerFeaturePages } from '@/data/explorerOperations'
import { configApi, type CapabilitiesResponse, type NetworksConfigResponse, type TokenListResponse } from '@/services/api/config'
import { tokensApi } from '@/services/api/tokens'
import { getMissionControlRelays, missionControlApi, type MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
import { routesApi, type RouteMatrixResponse } from '@/services/api/routes'
import { statsApi, type ExplorerStats } from '@/services/api/stats'
@@ -11,6 +12,7 @@ import OperationsPageShell, {
formatNumber,
relativeAge,
} from './OperationsPageShell'
import TokenListSurfaceNote from '@/components/common/TokenListSurfaceNote'
interface SystemOperationsPageProps {
initialBridgeStatus?: MissionControlBridgeStatusResponse | null
@@ -46,7 +48,7 @@ export default function SystemOperationsPage({
await Promise.allSettled([
missionControlApi.getBridgeStatus(),
configApi.getNetworks(),
configApi.getTokenList(),
tokensApi.listForSurface('extended', 138).then(({ ok, data }) => ({ tokens: ok ? data : [] })),
configApi.getCapabilities(),
routesApi.getRouteMatrix(),
statsApi.get(),
@@ -125,6 +127,7 @@ export default function SystemOperationsPage({
description={`${formatNumber(capabilities?.tracing?.supportedMethods?.length)} tracing methods published.`}
/>
</div>
<TokenListSurfaceNote className="mb-6 text-xs text-gray-500 dark:text-gray-400" />
<div className="mb-8 grid gap-6 lg:grid-cols-[1fr_1fr]">
<Card title="Topology Snapshot">

View File

@@ -105,7 +105,7 @@ export default function WethOperationsPage({
<OperationsPageShell page={page}>
<Card className="mb-6 border border-amber-200 bg-amber-50/70 dark:border-amber-900/50 dark:bg-amber-950/20">
<p className="text-sm leading-6 text-amber-950 dark:text-amber-100">
These WETH references are bridge and transport surfaces, not a claim that Ethereum mainnet WETH contracts are native Chain 138 assets.
These WETH references are bridge-lane and public-network representation surfaces, not a claim that Ethereum mainnet WETH contracts are native Chain 138 assets.
Use this page to review wrapped-asset lane posture, counterpart contracts, and operational dependencies.
</p>
</Card>

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react'
import { Card } from '@/libs/frontend-ui-primitives/Card'
import { Address } from '@/libs/frontend-ui-primitives'
import Link from 'next/link'
import { blocksApi, type Block } from '@/services/api/blocks'
import {
@@ -21,8 +22,17 @@ import { transactionsApi, type Transaction } from '@/services/api/transactions'
import { summarizeChainActivity } from '@/utils/activityContext'
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
import MarketEvidenceNote from '@/components/common/MarketEvidenceNote'
import { Explain, useUiMode } from '@/components/common/UiModeContext'
import { resolveEffectiveFreshness, shouldExplainEmptyHeadBlocks } from '@/utils/explorerFreshness'
import { tokenAggregationApi, type TokenAggregationTokenSnapshot } from '@/services/api/tokenAggregation'
import { tokensApi } from '@/services/api/tokens'
import {
HOME_DASHBOARD_REFRESH_MS,
HOME_PRICE_FEED_REFRESH_MS,
resolveHomePriceFeedAddresses,
} from '@/utils/featuredTokens'
import { createVisibilityAwarePoller } from '@/utils/visibilityRefresh'
type HomeStats = ExplorerStats
@@ -92,6 +102,15 @@ function compactStatNote(guided: string, expert: string, mode: 'guided' | 'exper
return mode === 'guided' ? guided : expert
}
function formatUsd(value: number | undefined) {
if (value == null || !Number.isFinite(value)) return 'Unavailable'
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: value >= 100 ? 0 : 2,
}).format(value)
}
export default function Home({
initialStats = null,
initialRecentBlocks = [],
@@ -109,8 +128,10 @@ export default function Home({
const [activitySnapshot, setActivitySnapshot] = useState<ExplorerRecentActivitySnapshot | null>(initialActivitySnapshot)
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
const [relaySummary, setRelaySummary] = useState<MissionControlRelaySummary | null>(initialRelaySummary)
const [featuredPrices, setFeaturedPrices] = useState<TokenAggregationTokenSnapshot[]>([])
const [missionExpanded, setMissionExpanded] = useState(false)
const [relayExpanded, setRelayExpanded] = useState(false)
const [statsDetailsExpanded, setStatsDetailsExpanded] = useState(false)
const [relayPage, setRelayPage] = useState(1)
const [relayFeedState, setRelayFeedState] = useState<'connecting' | 'live' | 'fallback'>(
initialRelaySummary || initialBridgeStatus ? 'fallback' : 'connecting'
@@ -144,8 +165,27 @@ export default function Home({
)
}, [chainId])
const loadFeaturedPrices = useCallback(async () => {
const [catalogResult, reportResult] = await Promise.all([
tokensApi.listForSurface('catalog', chainId),
tokensApi.listReportSafe(chainId),
])
const addresses = resolveHomePriceFeedAddresses(
catalogResult.ok ? catalogResult.data : [],
reportResult.ok ? reportResult.data : [],
)
const { data } = await tokenAggregationApi.getTokensByAddressSafe(chainId, addresses)
setFeaturedPrices(data)
}, [chainId])
useEffect(() => {
loadDashboard()
void loadDashboard()
return createVisibilityAwarePoller({
intervalMs: HOME_DASHBOARD_REFRESH_MS,
task: loadDashboard,
})
}, [loadDashboard])
useEffect(() => {
@@ -166,6 +206,33 @@ export default function Home({
}
}, [])
useEffect(() => {
let cancelled = false
const refreshFeaturedPrices = async () => {
try {
if (!cancelled) {
await loadFeaturedPrices()
}
} catch (error) {
if (!cancelled && process.env.NODE_ENV !== 'production') {
console.warn('Failed to load featured token prices:', error)
}
}
}
void refreshFeaturedPrices()
const stop = createVisibilityAwarePoller({
intervalMs: HOME_PRICE_FEED_REFRESH_MS,
task: refreshFeaturedPrices,
})
return () => {
cancelled = true
stop()
}
}, [loadFeaturedPrices])
useEffect(() => {
let cancelled = false
@@ -235,6 +302,31 @@ export default function Home({
}
}, [])
useEffect(() => {
if (relayFeedState !== 'fallback') return
let cancelled = false
const refreshSnapshot = async () => {
try {
const status = await missionControlApi.getBridgeStatus()
if (!cancelled) {
setBridgeStatus(status)
setRelaySummary(summarizeMissionControlRelay(status))
}
} catch (error) {
if (!cancelled && process.env.NODE_ENV !== 'production') {
console.warn('Failed to refresh mission control snapshot:', error)
}
}
}
return createVisibilityAwarePoller({
intervalMs: HOME_DASHBOARD_REFRESH_MS,
task: refreshSnapshot,
})
}, [relayFeedState])
const relayToneClasses =
relaySummary?.tone === 'danger'
? 'border-red-200 bg-red-50 text-red-900 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-100'
@@ -575,7 +667,7 @@ export default function Home({
<div className="rounded-2xl border border-white/40 bg-white/55 p-4 shadow-sm backdrop-blur dark:border-white/10 dark:bg-black/10">
<div className="text-xs font-semibold uppercase tracking-wide opacity-70">Chain 138 Status</div>
<div className="mt-2 text-lg font-semibold">{chainStatus.status || 'unknown'}</div>
<div className="mt-1 text-sm opacity-80">{chainStatus.name || 'Defi Oracle Meta Mainnet'}</div>
<div className="mt-1 text-sm opacity-80">{chainStatus.name || 'DeFi Oracle Meta Mainnet'}</div>
</div>
<div className="rounded-2xl border border-white/40 bg-white/55 p-4 shadow-sm backdrop-blur dark:border-white/10 dark:bg-black/10">
<div className="text-xs font-semibold uppercase tracking-wide opacity-70">Head Age</div>
@@ -681,9 +773,20 @@ export default function Home({
</Card>
)}
{(relaySummary || bridgeStatus || stats) && (
<div className="mb-4 flex flex-wrap items-center gap-2 rounded-xl border border-gray-200 bg-white/80 px-4 py-3 text-sm shadow-sm dark:border-gray-800 dark:bg-gray-950/60">
<EntityBadge label={`block ${latestBlock != null ? latestBlock.toLocaleString() : 'unknown'}`} tone="info" />
{chainStatus?.status ? <EntityBadge label={`chain ${chainStatus.status}`} tone={chainStatus.status === 'operational' ? 'success' : 'warning'} /> : null}
{relaySummary ? <EntityBadge label={`${relayOperationalCount} relays ok`} tone="success" /> : null}
<EntityBadge label={relayFeedState === 'live' ? 'live feed' : relayFeedState === 'fallback' ? 'snapshot feed' : 'connecting'} tone={relayFeedState === 'live' ? 'success' : 'info'} />
<span className="text-gray-600 dark:text-gray-400">{latestTransactionAgeLabel}</span>
</div>
)}
{stats && (
<div className="mb-8 space-y-4">
<div className="grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-4">
<div className="mb-6 space-y-4">
<p className="text-sm font-semibold text-gray-900 dark:text-white">Network overview</p>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
{primaryMetricCards.map((card) => (
<Card key={card.label}>
<div className="text-sm text-gray-600 dark:text-gray-400">{card.label}</div>
@@ -693,69 +796,109 @@ export default function Home({
))}
</div>
<div className="grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-3">
{activityMetricCards.map((card) => (
<Card key={card.label}>
<div className="text-sm text-gray-600 dark:text-gray-400">{card.label}</div>
<div className="text-xl font-bold sm:text-2xl">{card.value}</div>
<div className="mt-2 text-sm text-gray-700 dark:text-gray-300">{card.note}</div>
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">{card.detail}</div>
</Card>
))}
</div>
<button
type="button"
onClick={() => setStatsDetailsExpanded((current) => !current)}
className="text-sm font-semibold text-primary-600 hover:underline"
>
{statsDetailsExpanded ? 'Hide telemetry and freshness' : 'Show telemetry and freshness'}
</button>
{mode === 'guided' ? (
<div className="grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-4">
{secondaryMetricCards.map((card) => (
<Card key={card.label}>
<div className="text-sm text-gray-600 dark:text-gray-400">{card.label}</div>
<div className="text-xl font-bold sm:text-2xl">{card.value}</div>
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">{card.note}</div>
</Card>
))}
</div>
) : (
<Card>
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="text-sm font-semibold text-gray-900 dark:text-white">Telemetry Snapshot</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Secondary public stats in a denser expert layout.
</div>
</div>
<div className="grid flex-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
{statsDetailsExpanded ? (
<>
<div className="grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-3">
{activityMetricCards.map((card) => (
<Card key={card.label}>
<div className="text-sm text-gray-600 dark:text-gray-400">{card.label}</div>
<div className="text-xl font-bold sm:text-2xl">{card.value}</div>
<div className="mt-2 text-sm text-gray-700 dark:text-gray-300">{card.note}</div>
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">{card.detail}</div>
</Card>
))}
</div>
{mode === 'guided' ? (
<div className="grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-4">
{secondaryMetricCards.map((card) => (
<div key={card.label} className="rounded-2xl border border-gray-200 bg-gray-50/70 px-4 py-3 dark:border-gray-800 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{card.label}</div>
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">{card.value}</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">{card.note}</div>
</div>
<Card key={card.label}>
<div className="text-sm text-gray-600 dark:text-gray-400">{card.label}</div>
<div className="text-xl font-bold sm:text-2xl">{card.value}</div>
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">{card.note}</div>
</Card>
))}
</div>
</div>
</Card>
)}
) : (
<Card>
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="text-sm font-semibold text-gray-900 dark:text-white">Telemetry Snapshot</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Secondary public stats in a denser expert layout.
</div>
</div>
<div className="grid flex-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
{secondaryMetricCards.map((card) => (
<div key={card.label} className="rounded-2xl border border-gray-200 bg-gray-50/70 px-4 py-3 dark:border-gray-800 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{card.label}</div>
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">{card.value}</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">{card.note}</div>
</div>
))}
</div>
</div>
</Card>
)}
<ActivityContextPanel
context={activityContext}
title="Freshness Interpretation"
compact
/>
<FreshnessTrustNote
className="mt-3"
context={activityContext}
stats={stats}
bridgeStatus={bridgeStatus}
scopeLabel={
mode === 'guided'
? 'Homepage status combines chain freshness, transaction visibility, and mission-control posture.'
: 'Homepage freshness view aligns chain, transaction, and mission-control posture.'
}
/>
</>
) : null}
</div>
)}
<div className="mb-8">
<ActivityContextPanel
context={activityContext}
title="Freshness Interpretation"
compact
/>
<FreshnessTrustNote
className="mt-3"
context={activityContext}
stats={stats}
bridgeStatus={bridgeStatus}
scopeLabel={
mode === 'guided'
? 'Homepage status combines chain freshness, transaction visibility, and mission-control posture.'
: 'Homepage freshness view aligns chain, transaction, and mission-control posture.'
}
/>
</div>
{featuredPrices.length > 0 ? (
<div className="mb-8">
<Card title="Live Price Feed">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{featuredPrices.map((token) => (
<Link
key={token.address}
href={`/tokens/${token.address}`}
className="rounded-2xl border border-gray-200 bg-gray-50/70 px-4 py-3 transition hover:border-primary-400 hover:shadow-sm dark:border-gray-800 dark:bg-gray-900/40"
>
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{token.symbol || token.name || 'Token'}
</div>
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
{formatUsd(token.market?.priceUsd)}
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
Visible liquidity: {formatUsd(token.market?.liquidityUsd)}
</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{token.market?.lastUpdated ? `Updated ${formatRelativeAge(token.market.lastUpdated)}` : 'Update time unavailable'}
</div>
<MarketEvidenceNote lastUpdated={token.market?.lastUpdated} compact />
</Link>
))}
</div>
</Card>
</div>
) : null}
{!stats && (
<Card className="mb-8">
@@ -765,7 +908,46 @@ export default function Home({
</Card>
)}
<Card title="Recent Blocks">
<div className="mb-8 grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card title="Recent Transactions">
{recentTransactions.length === 0 ? (
<p className="text-sm text-gray-600 dark:text-gray-400">
Recent transactions are unavailable right now.
</p>
) : (
<div className="space-y-2">
{recentTransactions.map((transaction) => (
<div key={transaction.hash} className="flex flex-col gap-1.5 border-b border-gray-200 py-2 last:border-0 dark:border-gray-700 sm:flex-row sm:items-center sm:justify-between">
<div>
<Link href={`/transactions/${transaction.hash}`} className="text-primary-600 hover:underline">
{transaction.hash.slice(0, 10)}...{transaction.hash.slice(-8)}
</Link>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Block #{transaction.block_number} · from{' '}
<Address address={transaction.from_address} truncate showCopy={false} />
{transaction.to_address ? (
<>
{' '} <Address address={transaction.to_address} truncate showCopy={false} />
</>
) : null}
</div>
</div>
<div className="text-sm text-gray-600 dark:text-gray-400 sm:text-right">
<div>{formatWeiAsEth(transaction.value, 4)}</div>
<div className="text-xs">{formatTimestamp(transaction.created_at)}</div>
</div>
</div>
))}
</div>
)}
<div className="mt-4">
<Link href="/transactions" className="text-primary-600 hover:underline">
View all transactions
</Link>
</div>
</Card>
<Card title="Recent Blocks">
{recentBlocks.length === 0 ? (
<p className="text-sm text-gray-600 dark:text-gray-400">
Recent blocks are unavailable right now.
@@ -803,120 +985,38 @@ export default function Home({
View all blocks
</Link>
</div>
</Card>
<div className="mt-8 grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card title="Activity Pulse">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
{mode === 'guided'
? 'A concise public view of chain activity, index coverage, and recent execution patterns.'
: 'Public chain activity and index posture.'}
</p>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Latest Daily Volume</div>
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
{latestTrendPoint ? latestTrendPoint.transaction_count.toLocaleString() : 'Unknown'}
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">{latestTrendPoint?.date || 'Trend feed unavailable'}</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Recent Success Rate</div>
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
{activitySnapshot ? `${Math.round(activitySnapshot.success_rate * 100)}%` : 'Unknown'}
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
{activitySnapshot ? `${activitySnapshot.sample_size} sampled transactions` : 'Recent activity snapshot unavailable'}
</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Avg Recent Fee</div>
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
{activitySnapshot ? formatWeiAsEth(Math.round(activitySnapshot.average_fee_wei).toString(), 6) : 'Unknown'}
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">Average fee from the recent public sample.</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Peak Charted Day</div>
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
{peakTrendPoint ? peakTrendPoint.transaction_count.toLocaleString() : 'Unknown'}
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">{peakTrendPoint?.date || 'No trend data yet'}</div>
</div>
</div>
<div className="mt-4">
<Link href="/analytics" className="text-primary-600 hover:underline">
Open full analytics
</Link>
</div>
</Card>
<Card title="Explorer Shortcuts">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
Go directly to the explorer surfaces that provide the strongest operational and discovery context.
</p>
<div className="mt-4 flex flex-wrap gap-3 text-sm">
<Link href="/search" className="text-primary-600 hover:underline">
Search
</Link>
<Link href="/transactions" className="text-primary-600 hover:underline">
Transactions
</Link>
<Link href="/tokens" className="text-primary-600 hover:underline">
Tokens
</Link>
<Link href="/addresses" className="text-primary-600 hover:underline">
Addresses
</Link>
<Link href="/analytics" className="text-primary-600 hover:underline">
Analytics
</Link>
</div>
</Card>
<Card title="Liquidity & Routes">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
Explore the public Chain 138 DODO PMM liquidity mesh, the canonical route matrix, and the
partner payload endpoints exposed through the explorer.
</p>
<div className="mt-4">
<Link href="/routes" className="text-primary-600 hover:underline">
Open routes and liquidity
</Link>
</div>
</Card>
<Card title="Wallet & Token Discovery">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
Add Chain 138, Ethereum Mainnet, and ALL Mainnet to MetaMask, then use the explorer token
list URL so supported tokens appear automatically.
</p>
<div className="mt-4">
<Link href="/wallet" className="text-primary-600 hover:underline">
Open wallet tools
</Link>
</div>
</Card>
<Card title="Bridge & Relay Monitoring">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
Open the public bridge monitoring surface for relay status, mission-control links, bridge trace tooling,
and the visual command center entry points.
</p>
<div className="mt-4">
<Link href="/bridge" className="text-primary-600 hover:underline">
Open bridge monitoring
</Link>
</div>
</Card>
<Card title="Operations Hub">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
Open the public operations surface for wrapped-asset references, analytics shortcuts, operator links,
system topology views, and other Chain 138 support tools.
</p>
<div className="mt-4">
<Link href="/operations" className="text-primary-600 hover:underline">
Open operations hub
</Link>
</div>
</Card>
</div>
<Card title="Quick links" className="mt-8">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
Jump to the explorer surfaces used most often for discovery, liquidity, wallet setup, and bridge monitoring.
</p>
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<Link href="/search" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
Search
</Link>
<Link href="/tokens" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
Tokens
</Link>
<Link href="/wallet" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
Wallet & MetaMask
</Link>
<Link href="/routes" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
Routes
</Link>
<Link href="/liquidity" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
Liquidity
</Link>
<Link href="/bridge" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
Bridge
</Link>
<Link href="/analytics" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
Analytics
</Link>
</div>
</Card>
</main>
)
}

View File

@@ -2,6 +2,8 @@
import { useEffect, useMemo, useState } from 'react'
import { resolveExplorerApiBase } from '@/libs/frontend-api-client/api-base'
import { tokensApi } from '@/services/api/tokens'
import { selectWalletFeaturedTokens } from '@/utils/featuredTokens'
export type WalletChain = {
chainId: string
@@ -84,6 +86,32 @@ export type CapabilitiesCatalog = {
}
}
type WatchAssetEntry = {
type: 'ERC20'
options: {
address: string
symbol: string
decimals: number
image?: string
}
metadata?: {
name?: string
registryFamily?: string
familySymbol?: string
deploymentVersion?: string
deploymentStatus?: string
}
}
type MetaMaskConfig = {
source?: string
version?: string
chainId?: number
addEthereumChain?: WalletChain
watchAssets?: WatchAssetEntry[]
caveats?: string[]
}
export type FetchMetadata = {
source?: string | null
lastModified?: string | null
@@ -109,7 +137,11 @@ const FALLBACK_CHAIN_138: WalletChain = {
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
rpcUrls: ['https://rpc-http-pub.d-bis.org', 'https://rpc.d-bis.org', 'https://rpc2.d-bis.org'],
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'],
iconUrls: [
'https://explorer.d-bis.org/api/v1/report/logo/chain-138',
'https://explorer.d-bis.org/token-icons/chain-138.png',
'https://explorer.d-bis.org/favicon.ico',
],
shortName: 'dbis',
infoURL: 'https://explorer.d-bis.org',
explorerApiUrl: 'https://explorer.d-bis.org/api/v2',
@@ -139,7 +171,21 @@ const FALLBACK_ALL_MAINNET: WalletChain = {
infoURL: 'https://alltra.global',
}
const FEATURED_TOKEN_SYMBOLS = ['cUSDT', 'cUSDC', 'USDT', 'USDC', 'cXAUC', 'cXAUT']
const MAINNET_CWUSDC_TOKEN: TokenListToken = {
chainId: 1,
address: '0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a',
symbol: 'cWUSDC',
name: 'Wrapped cUSDC',
decimals: 6,
logoURI: 'https://explorer.d-bis.org/api/v1/report/logo/cUSDC?v=20260510',
tags: ['mainnet', 'cw', 'usd'],
extensions: {
registryFamily: 'iso4217',
familySymbol: 'USD',
canonicalSourceChainId: 138,
canonicalSourceSymbol: 'cUSDC',
},
}
/** npm-published Snap using open Snap permissions only; stable MetaMask still requires MetaMasks install allowlist. */
const CHAIN138_OPEN_SNAP_ID = 'npm:chain138-open-snap' as const
@@ -148,7 +194,7 @@ const FALLBACK_CAPABILITIES_138: CapabilitiesCatalog = {
name: 'Chain 138 RPC Capabilities',
version: { major: 1, minor: 1, patch: 0 },
timestamp: '2026-03-28T00:00:00Z',
generatedBy: 'SolaceScan',
generatedBy: 'DBIS Explorer',
chainId: 138,
chainName: 'DeFi Oracle Meta Mainnet',
rpcUrl: 'https://rpc-http-pub.d-bis.org',
@@ -218,12 +264,62 @@ function isCapabilitiesCatalog(value: unknown): value is CapabilitiesCatalog {
)
}
function isWatchAssetEntry(value: unknown): value is WatchAssetEntry {
if (!value || typeof value !== 'object') return false
const candidate = value as Partial<WatchAssetEntry>
const options = (candidate.options || {}) as Partial<WatchAssetEntry['options']>
return (
candidate.type === 'ERC20' &&
typeof options.address === 'string' &&
options.address.trim().length > 0 &&
typeof options.symbol === 'string' &&
options.symbol.trim().length > 0 &&
typeof options.decimals === 'number'
)
}
function isMetaMaskConfig(value: unknown): value is MetaMaskConfig {
if (!value || typeof value !== 'object') return false
const candidate = value as Partial<MetaMaskConfig>
return (
typeof candidate.chainId === 'number' &&
!!candidate.addEthereumChain &&
Array.isArray(candidate.watchAssets)
)
}
function watchAssetToToken(entry: WatchAssetEntry): TokenListToken {
return {
chainId: 138,
address: entry.options.address,
symbol: entry.options.symbol,
name: entry.metadata?.name || entry.options.symbol,
decimals: entry.options.decimals,
logoURI: entry.options.image,
extensions: {
registryFamily: entry.metadata?.registryFamily,
familySymbol: entry.metadata?.familySymbol,
deploymentVersion: entry.metadata?.deploymentVersion,
deploymentStatus: entry.metadata?.deploymentStatus,
},
}
}
function getApiBase() {
return resolveExplorerApiBase({
serverFallback: 'https://blockscout.defi-oracle.io',
browserOrigin: '',
serverFallback: 'https://explorer.d-bis.org',
})
}
function formatStableTimestamp(value: string): string {
const timestamp = Date.parse(value)
if (Number.isNaN(timestamp)) return value
return new Date(timestamp).toISOString()
}
export function AddToMetaMask({
initialNetworks = null,
initialTokenList = null,
@@ -253,19 +349,21 @@ export function AddToMetaMask({
lastModified: FALLBACK_CAPABILITIES_138.timestamp || null,
}),
)
const [metamaskConfig, setMetamaskConfig] = useState<MetaMaskConfig | null>(null)
const [metamaskConfigMeta, setMetamaskConfigMeta] = useState<FetchMetadata | null>(null)
const [curatedTokens, setCuratedTokens] = useState<TokenListToken[]>([])
const [watchAssetProgress, setWatchAssetProgress] = useState<{ current: number; total: number } | null>(null)
const ethereum = typeof window !== 'undefined'
? (window as unknown as { ethereum?: EthereumProvider }).ethereum
: undefined
const apiBase = getApiBase().replace(/\/$/, '')
const tokenListUrl = `${apiBase}/api/config/token-list`
const tokenListUrl = `${apiBase}/api/v1/report/token-list?chainId=138`
const networksUrl = `${apiBase}/api/config/networks`
const metamaskConfigUrl = `${apiBase}/api/v1/config/metamask?chainId=138`
const capabilitiesUrl = `${apiBase}/api/config/capabilities`
const staticCapabilitiesUrl =
typeof window !== 'undefined'
? `${window.location.origin.replace(/\/$/, '')}/config/CHAIN138_RPC_CAPABILITIES.json`
: `${apiBase}/config/CHAIN138_RPC_CAPABILITIES.json`
const staticCapabilitiesUrl = `${apiBase}/config/CHAIN138_RPC_CAPABILITIES.json`
useEffect(() => {
let active = true
@@ -293,6 +391,7 @@ export function AddToMetaMask({
fetchJson(tokenListUrl),
fetchJson(capabilitiesUrl),
])
const metamaskConfigResponse = await fetchJson(metamaskConfigUrl).catch(() => null)
let resolvedCapabilities = capabilitiesResponse
if (!isCapabilitiesCatalog(resolvedCapabilities.json)) {
@@ -320,6 +419,10 @@ export function AddToMetaMask({
setNetworks(networksResponse.json)
setTokenList(tokenListResponse.json)
setCapabilities(resolvedCapabilities.json)
if (isMetaMaskConfig(metamaskConfigResponse?.json)) {
setMetamaskConfig(metamaskConfigResponse.json)
setMetamaskConfigMeta(metamaskConfigResponse.meta)
}
setNetworksMeta(networksResponse.meta)
setTokenListMeta(tokenListResponse.meta)
setCapabilitiesMeta(resolvedCapabilities.meta)
@@ -328,6 +431,7 @@ export function AddToMetaMask({
setNetworks((current) => current)
setTokenList((current) => current)
setCapabilities((current) => current || FALLBACK_CAPABILITIES_138)
setMetamaskConfig((current) => current)
setNetworksMeta((current) => current)
setTokenListMeta((current) => current)
setCapabilitiesMeta((current) =>
@@ -351,7 +455,25 @@ export function AddToMetaMask({
active = false
if (timer) clearTimeout(timer)
}
}, [capabilitiesUrl, networksUrl, staticCapabilitiesUrl, tokenListUrl])
}, [capabilitiesUrl, metamaskConfigUrl, networksUrl, staticCapabilitiesUrl, tokenListUrl])
useEffect(() => {
let active = true
tokensApi.listForSurface('wallet', 138).then(({ ok, data }) => {
if (active) {
setCuratedTokens(ok ? (data as TokenListToken[]) : [])
}
}).catch(() => {
if (active) {
setCuratedTokens([])
}
})
return () => {
active = false
}
}, [])
const catalogTokens = useMemo(
() => (Array.isArray(tokenList?.tokens) ? tokenList.tokens.filter(isTokenListToken) : []),
@@ -367,25 +489,26 @@ export function AddToMetaMask({
}
return {
chain138: chainMap.get(138) || FALLBACK_CHAIN_138,
chain138: metamaskConfig?.addEthereumChain || chainMap.get(138) || FALLBACK_CHAIN_138,
ethereum: chainMap.get(1) || FALLBACK_ETHEREUM,
allMainnet: chainMap.get(651940) || FALLBACK_ALL_MAINNET,
total: (networks?.chains || []).length,
}
}, [networks])
}, [metamaskConfig, networks])
const featuredTokens = useMemo(() => {
const tokenMap = new Map<string, TokenListToken>()
for (const token of catalogTokens) {
if (token.chainId !== 138) continue
if (!FEATURED_TOKEN_SYMBOLS.includes(token.symbol)) continue
tokenMap.set(token.symbol, token)
}
const featuredTokens = useMemo(
() => selectWalletFeaturedTokens(catalogTokens, curatedTokens) as TokenListToken[],
[catalogTokens, curatedTokens],
)
return FEATURED_TOKEN_SYMBOLS
.map((symbol) => tokenMap.get(symbol))
.filter((token): token is TokenListToken => !!token)
}, [catalogTokens])
const watchAssetTokens = useMemo(() => {
const endpointTokens = (metamaskConfig?.watchAssets || [])
.filter(isWatchAssetEntry)
.map(watchAssetToToken)
if (endpointTokens.length > 0) return endpointTokens
return catalogTokens.filter((token) => token.chainId === 138)
}, [catalogTokens, metamaskConfig])
const addChain = async (chain: WalletChain) => {
setError(null)
@@ -412,6 +535,39 @@ export function AddToMetaMask({
}
}
const switchOrAddChain = async (chain: WalletChain) => {
if (!ethereum) {
setError('MetaMask or another Web3 wallet is not installed.')
return false
}
try {
await ethereum.request({
method: 'wallet_switchEthereumChain',
params: [{ chainId: chain.chainId }],
})
return true
} catch (e) {
const err = e as { code?: number; message?: string }
if (err.code !== 4902) {
setError(err.message || `Failed to switch to ${chain.chainName}.`)
return false
}
}
try {
await ethereum.request({
method: 'wallet_addEthereumChain',
params: [chain],
})
return true
} catch (e) {
const err = e as { message?: string }
setError(err.message || `Failed to add ${chain.chainName}.`)
return false
}
}
const installOpenSnap = async () => {
setError(null)
setStatus(null)
@@ -435,7 +591,7 @@ export function AddToMetaMask({
const allowlistBlocked = /allowlist/i.test(msg)
if (allowlistBlocked && msg) {
setError(
`${msg} Production MetaMask only installs allowlisted Snaps from npm. Use MetaMask Flask for unrestricted installs during development, or request allowlisting via MetaMasks Snaps documentation.`,
`${msg} This is expected on Stable MetaMask until this exact Snap package and version are accepted on MetaMask's install allowlist. The production path on this page is Add Chain 138 plus EIP-747 Add Tokens; use MetaMask Flask for Snap testing or submit/update the Snap allowlist request before using this button with Stable MetaMask.`,
)
} else {
setError(
@@ -481,6 +637,63 @@ export function AddToMetaMask({
}
}
const refreshMainnetCwusdc = async () => {
setError(null)
setStatus(null)
const switched = await switchOrAddChain(chains.ethereum)
if (!switched) return
await watchToken(MAINNET_CWUSDC_TOKEN)
}
const watchTokensSequentially = async (tokens: TokenListToken[], label: string) => {
setError(null)
setStatus(null)
setWatchAssetProgress(null)
if (!ethereum) {
setError('MetaMask or another Web3 wallet is not installed.')
return
}
const validTokens = tokens.filter(isTokenListToken)
if (validTokens.length === 0) {
setError('No complete token metadata is available for wallet_watchAsset right now.')
return
}
let addedCount = 0
for (let index = 0; index < validTokens.length; index += 1) {
const token = validTokens[index]
setWatchAssetProgress({ current: index + 1, total: validTokens.length })
try {
const added = await ethereum.request({
method: 'wallet_watchAsset',
params: {
type: 'ERC20',
options: {
address: token.address,
symbol: token.symbol,
decimals: token.decimals,
image: token.logoURI,
},
},
})
if (added) addedCount += 1
} catch (e) {
const err = e as { message?: string }
setError(err.message || `Stopped while adding ${token.symbol}.`)
setStatus(`${addedCount} of ${validTokens.length} ${label} token requests were accepted before the flow stopped.`)
setWatchAssetProgress(null)
return
}
}
setWatchAssetProgress(null)
setStatus(`${addedCount} of ${validTokens.length} ${label} token requests were accepted by the wallet.`)
}
const copyText = async (value: string, label: string) => {
setError(null)
setStatus(null)
@@ -510,8 +723,8 @@ export function AddToMetaMask({
The wallet tools now read the same explorer-served network catalog and token list that MetaMask can consume.
That keeps chain metadata, token metadata, and optional extensions aligned with the live explorer API instead of
relying on stale frontend-only defaults. MetaMask does not run built-in token detection on custom networks such
as Chain 138: add the token list URL below under Settings Security & privacy Token lists so tokens and
icons load automatically when you are on this chain.
as Chain 138, so this page uses EIP-747 wallet_watchAsset prompts from the live MetaMask payload to add token
metadata directly to the wallet.
</p>
<div className="grid gap-3 md:grid-cols-3">
@@ -538,17 +751,19 @@ export function AddToMetaMask({
</button>
</div>
<div className="rounded-lg border border-primary-200 bg-primary-50/40 p-4 dark:border-primary-900 dark:bg-primary-950/20">
<div className="text-sm font-semibold text-gray-900 dark:text-white">Chain 138 Open Snap</div>
<div className="rounded-lg border border-amber-200 bg-amber-50/50 p-4 dark:border-amber-900 dark:bg-amber-950/20">
<div className="text-sm font-semibold text-gray-900 dark:text-white">Optional Chain 138 Open Snap</div>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
Optional MetaMask Snap that uses{' '}
This is <span className="font-medium text-gray-800 dark:text-gray-200">not required</span> for the production
wallet flow above. The normal production path is to add Chain 138, then add tokens through EIP-747
wallet_watchAsset prompts. The optional Snap uses{' '}
<span className="font-medium text-gray-800 dark:text-gray-200">only open Snap permissions</span> (minimal
privileged APIs in the Snap itself).{' '}
<span className="font-medium text-gray-800 dark:text-gray-200">Stable MetaMask</span> still only installs npm
Snaps that appear on MetaMask&apos;s install allowlist; if install fails with &quot;not on the allowlist&quot;,
use <span className="font-medium text-gray-800 dark:text-gray-200">MetaMask Flask</span> for development or apply
for allowlisting. It adds in-wallet weekly reminders, Chain 138 transaction/signature hints, and the token list
URL on the Snap home page. The package on npm is{' '}
Snaps that appear on MetaMask&apos;s install allowlist; if install fails with &quot;not on the allowlist&quot;, that is
an external MetaMask review gate rather than an explorer/network failure. Use{' '}
<span className="font-medium text-gray-800 dark:text-gray-200">MetaMask Flask</span> for development or apply
for allowlisting before using this with Stable MetaMask. The package on npm is{' '}
<code className="break-all rounded bg-gray-100 px-1 text-xs dark:bg-gray-900">{CHAIN138_OPEN_SNAP_ID}</code>
publish from the repo with <code className="break-all rounded bg-gray-100 px-1 text-xs dark:bg-gray-900">scripts/deployment/publish-chain138-open-snap.sh</code> after{' '}
<code className="rounded bg-gray-100 px-1 text-xs dark:bg-gray-900">npm login</code>.
@@ -556,9 +771,9 @@ export function AddToMetaMask({
<button
type="button"
onClick={() => void installOpenSnap()}
className="mt-3 rounded bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700"
className="mt-3 rounded bg-amber-700 px-4 py-2 text-sm font-medium text-white hover:bg-amber-800"
>
Install Open Snap
Install Snap (Flask or allowlisted Stable)
</button>
</div>
@@ -568,8 +783,10 @@ export function AddToMetaMask({
<div className="mt-3 space-y-2 text-sm text-gray-600 dark:text-gray-400">
<p>Networks catalog: {chains.total > 0 ? `${chains.total} chains` : 'using frontend fallback values'}</p>
<p>Chain 138 token entries: {tokenCount138}</p>
<p>EIP-747 watchAsset entries: {watchAssetTokens.length}</p>
<p>Networks source: {networksMeta?.source || 'unknown'}</p>
<p>Token list source: {tokenListMeta?.source || 'unknown'}</p>
<p>MetaMask payload source: {metamaskConfigMeta?.source || 'unknown'}</p>
{metadataKeywordString ? <p>Keywords: {metadataKeywordString}</p> : null}
</div>
<div className="mt-4 space-y-3">
@@ -597,6 +814,18 @@ export function AddToMetaMask({
</a>
</div>
</div>
<div>
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">EIP-747 MetaMask payload URL</p>
<code className="block break-all rounded bg-gray-100 p-2 text-xs dark:bg-gray-900">{metamaskConfigUrl}</code>
<div className="mt-2 flex flex-wrap gap-2">
<button type="button" onClick={() => copyText(metamaskConfigUrl, 'MetaMask payload URL')} className="rounded bg-gray-100 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700">
Copy URL
</button>
<a href={metamaskConfigUrl} target="_blank" rel="noopener noreferrer" className="rounded bg-gray-100 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700">
Open JSON
</a>
</div>
</div>
<div>
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Token list URL</p>
<code className="block break-all rounded bg-gray-100 p-2 text-xs dark:bg-gray-900">{tokenListUrl}</code>
@@ -653,7 +882,7 @@ export function AddToMetaMask({
))}
{capabilitiesMeta?.lastModified ? (
<p className="text-xs">
Last modified: {new Date(capabilitiesMeta.lastModified).toLocaleString()}
Last modified: {formatStableTimestamp(capabilitiesMeta.lastModified)}
</p>
) : null}
</div>
@@ -662,9 +891,31 @@ export function AddToMetaMask({
<div className="rounded-lg border border-gray-200 p-4 dark:border-gray-700">
<div className="text-sm font-semibold text-gray-900 dark:text-white">Featured Chain 138 tokens</div>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
These tokens come from the explorer token list and use `wallet_watchAsset` so the wallet gets the same symbol,
decimals, image, and optional token metadata that the explorer publishes.
These tokens come from the explorer MetaMask payload and use wallet_watchAsset so the wallet gets the same
symbol, decimals, image, and optional token metadata that the explorer publishes. MetaMask requires a user
approval for each token, so the bulk actions below run as a guided sequence of wallet prompts.
</p>
<div className="mt-4 flex flex-wrap gap-2">
<button
type="button"
onClick={() => void watchTokensSequentially(featuredTokens, 'featured Chain 138')}
className="rounded bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700"
>
Add featured tokens
</button>
<button
type="button"
onClick={() => void watchTokensSequentially(watchAssetTokens, 'Chain 138')}
className="rounded bg-gray-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-gray-700"
>
Add all Chain 138 tokens
</button>
{watchAssetProgress ? (
<span className="self-center text-sm text-gray-600 dark:text-gray-400">
Prompt {watchAssetProgress.current} of {watchAssetProgress.total}
</span>
) : null}
</div>
<div className="mt-4 space-y-3">
{featuredTokens.length === 0 ? (
<p className="text-sm text-gray-600 dark:text-gray-400">Featured token metadata is not available right now.</p>
@@ -698,6 +949,35 @@ export function AddToMetaMask({
))}
</div>
</div>
<div className="rounded-lg border border-gray-200 p-4 dark:border-gray-700">
<div className="text-sm font-semibold text-gray-900 dark:text-white">Ethereum Mainnet cWUSDC</div>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
This refreshes the Mainnet cWUSDC custom asset metadata with the DBIS-hosted image URL. MetaMask fiat price
display still depends on MetaMask and upstream asset/price providers accepting the Mainnet listing.
</p>
<div className="mt-4 rounded-lg border border-gray-200 p-3 dark:border-gray-700">
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<div className="text-sm font-semibold text-gray-900 dark:text-white">
{MAINNET_CWUSDC_TOKEN.symbol}{' '}
<span className="font-normal text-gray-500 dark:text-gray-400">({MAINNET_CWUSDC_TOKEN.name})</span>
</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">{MAINNET_CWUSDC_TOKEN.address}</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Ethereum Mainnet Decimals: {MAINNET_CWUSDC_TOKEN.decimals}
</div>
</div>
<button
type="button"
onClick={() => void refreshMainnetCwusdc()}
className="rounded bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700"
>
Refresh Mainnet cWUSDC
</button>
</div>
</div>
</div>
</div>
{status ? <p className="text-sm text-green-600 dark:text-green-400">{status}</p> : null}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import type {
CapabilitiesCatalog,
FetchMetadata,
@@ -17,6 +17,8 @@ import {
type AddressTokenTransfer,
type TransactionSummary,
} from '@/services/api/addresses'
import { WALLET_SNAPSHOT_REFRESH_MS } from '@/utils/featuredTokens'
import { createVisibilityAwarePoller } from '@/utils/visibilityRefresh'
import { formatRelativeAge, formatTokenAmount } from '@/utils/format'
import {
isWatchlistEntry,
@@ -110,9 +112,41 @@ export default function WalletPage(props: WalletPageProps) {
? isWatchlistEntry(watchlistEntries, walletSession.address)
: false
const loadWalletSnapshot = useCallback(async (address: string) => {
const [infoResponse, transactionsResponse, balancesResponse, transfersResponse] = await Promise.all([
addressesApi.getSafe(138, address),
addressesApi.getTransactionsSafe(138, address, 1, 3),
addressesApi.getTokenBalancesSafe(address),
addressesApi.getTokenTransfersSafe(address, 1, 4),
])
setAddressInfo(infoResponse.ok ? infoResponse.data : null)
setRecentAddressTransactions(transactionsResponse.ok ? transactionsResponse.data : [])
setTokenBalances(
balancesResponse.ok
? [...balancesResponse.data]
.filter((balance) => {
try {
return BigInt(balance.value || '0') > 0n
} catch {
return Boolean(balance.value)
}
})
.sort((left, right) => {
try {
return Number(BigInt(right.value || '0') - BigInt(left.value || '0'))
} catch {
return 0
}
})
.slice(0, 4)
: [],
)
setTokenTransfers(transfersResponse.ok ? transfersResponse.data : [])
}, [])
useEffect(() => {
let cancelled = false
if (!walletSession?.address) {
setAddressInfo(null)
setRecentAddressTransactions([])
@@ -123,50 +157,32 @@ export default function WalletPage(props: WalletPageProps) {
}
}
Promise.all([
addressesApi.getSafe(138, walletSession.address),
addressesApi.getTransactionsSafe(138, walletSession.address, 1, 3),
addressesApi.getTokenBalancesSafe(walletSession.address),
addressesApi.getTokenTransfersSafe(walletSession.address, 1, 4),
])
.then(([infoResponse, transactionsResponse, balancesResponse, transfersResponse]) => {
if (cancelled) return
setAddressInfo(infoResponse.ok ? infoResponse.data : null)
setRecentAddressTransactions(transactionsResponse.ok ? transactionsResponse.data : [])
setTokenBalances(
balancesResponse.ok
? [...balancesResponse.data]
.filter((balance) => {
try {
return BigInt(balance.value || '0') > 0n
} catch {
return Boolean(balance.value)
}
})
.sort((left, right) => {
try {
return Number(BigInt(right.value || '0') - BigInt(left.value || '0'))
} catch {
return 0
}
})
.slice(0, 4)
: [],
)
setTokenTransfers(transfersResponse.ok ? transfersResponse.data : [])
})
.catch(() => {
if (cancelled) return
setAddressInfo(null)
setRecentAddressTransactions([])
setTokenBalances([])
setTokenTransfers([])
})
const refreshSnapshot = async () => {
try {
if (!cancelled) {
await loadWalletSnapshot(walletSession.address)
}
} catch {
if (!cancelled) {
setAddressInfo(null)
setRecentAddressTransactions([])
setTokenBalances([])
setTokenTransfers([])
}
}
}
void refreshSnapshot()
const stop = createVisibilityAwarePoller({
intervalMs: WALLET_SNAPSHOT_REFRESH_MS,
task: refreshSnapshot,
})
return () => {
cancelled = true
stop()
}
}, [walletSession?.address])
}, [loadWalletSnapshot, walletSession?.address])
return (
<main className="container mx-auto px-4 py-6 sm:py-8">

View File

@@ -11,6 +11,8 @@ export interface ExplorerFeaturePage {
title: string
description: string
note?: string
accessTrack?: number
accessNote?: string
actions: ExplorerFeatureAction[]
}
@@ -141,6 +143,9 @@ export const explorerFeaturePages = {
description:
'Use the public explorer pages and live monitoring endpoints as the visible analytics surface for chain activity, recent blocks, and transaction flow.',
note: sharedOperationsNote,
accessTrack: 3,
accessNote:
'This page is the public Track 3 analytics surface. Wallet-authenticated Track 3 APIs remain available after browser wallet sign-in.',
actions: [
{
title: 'Blocks',
@@ -175,6 +180,9 @@ export const explorerFeaturePages = {
description:
'Expose the public operator surface for bridge checks, route validation, planner providers, liquidity entry points, and documentation.',
note: sharedOperationsNote,
accessTrack: 4,
accessNote:
'This page is the public Track 4 operator surface. Sensitive operator write APIs remain gated behind wallet auth and operator policy.',
actions: [
{
title: 'Bridge monitoring',
@@ -296,3 +304,85 @@ export const explorerFeaturePages = {
],
},
} as const satisfies Record<string, ExplorerFeaturePage>
export interface ExplorerOperationsSurface {
href: string
label: string
description: string
}
export const explorerOperationsSurfaces: ExplorerOperationsSurface[] = [
{
href: '/operations',
label: 'Operations hub',
description: 'Consolidated monitoring, config, and route inventory.',
},
{
href: '/bridge',
label: 'Bridge',
description: 'Relay lanes, mission-control feed, and CCIP routes.',
},
{
href: '/routes',
label: 'Routes',
description: 'Live route matrix and execution paths.',
},
{
href: '/liquidity',
label: 'Liquidity',
description: 'PMM access points and planner capabilities.',
},
{
href: '/weth',
label: 'WETH',
description: 'Wrapped-asset references and bridge context.',
},
{
href: '/pools',
label: 'Pools',
description: 'Mission-control pool inventory snapshot.',
},
{
href: '/system',
label: 'System',
description: 'Networks, RPC methods, and topology inventory.',
},
{
href: '/analytics',
label: 'Analytics',
description: 'Track 3 activity summaries, trends, and freshness context.',
},
{
href: '/operator',
label: 'Operator',
description: 'Track 4 relay, route, and planner shortcuts.',
},
]
export const explorerPublicApiLinks = [
{
href: '/api/v2/stats',
label: 'Blockscout stats',
description: 'Chain head, gas, and indexer summary.',
},
{
href: '/explorer-api/v1/track1/bridge/status',
label: 'Bridge status JSON',
description: 'Mission-control relay posture snapshot.',
},
{
href: '/token-aggregation/api/v1/routes/matrix?includeNonLive=true',
label: 'Route matrix',
description: 'Token-aggregation live and planned routes.',
},
{
href: '/api/config/networks',
label: 'Wallet networks',
description: 'Published chain metadata for wallet onboarding.',
},
{
href: '/explorer-api/v1/walletconnect/config',
label: 'WalletConnect config',
description: 'Published WalletConnect v2 posture and browser-auth fallback.',
},
] as const

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/router'
import { Card, Table, Address } from '@/libs/frontend-ui-primitives'
import Link from 'next/link'
@@ -28,14 +28,31 @@ import {
normalizeWatchlistAddress,
} from '@/utils/watchlist'
import PageIntro from '@/components/common/PageIntro'
import PaginationControls from '@/components/common/PaginationControls'
import SectionTabs, { type SectionTab } from '@/components/common/SectionTabs'
import GruStandardsCard from '@/components/common/GruStandardsCard'
import ContractCodeWorkspace from '@/components/explorer/ContractCodeWorkspace'
import ContractVerificationCallout from '@/components/explorer/ContractVerificationCallout'
import { getGruStandardsProfileSafe, type GruStandardsProfile } from '@/services/api/gru'
import { getGruExplorerMetadata } from '@/services/api/gruExplorerData'
import { tokenAggregationApi, type TokenAggregationTokenSnapshot } from '@/services/api/tokenAggregation'
import { estimateNativeUsdValue, getNativeAssetDescriptor, getNativeAssetMarketSafe } from '@/services/api/nativeAssetPricing'
function isValidAddress(value: string) {
return /^0x[a-fA-F0-9]{40}$/.test(value)
}
function formatUsd(value: string | number | undefined): string {
if (value == null) return 'Unavailable'
const numeric = typeof value === 'number' ? value : Number(value)
if (!Number.isFinite(numeric)) return 'Unavailable'
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: numeric >= 100 ? 0 : 2,
}).format(numeric)
}
export default function AddressDetailPage() {
const router = useRouter()
const address = typeof router.query.address === 'string' ? router.query.address : ''
@@ -51,7 +68,14 @@ export default function AddressDetailPage() {
const [watchlistEntries, setWatchlistEntries] = useState<string[]>([])
const [methodResults, setMethodResults] = useState<Record<string, { loading: boolean; value?: string; error?: string }>>({})
const [methodInputs, setMethodInputs] = useState<Record<string, string[]>>({})
const [tokenMarkets, setTokenMarkets] = useState<Record<string, TokenAggregationTokenSnapshot>>({})
const [nativeAssetPriceUsd, setNativeAssetPriceUsd] = useState<number | undefined>()
const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState<'contract' | 'balances' | 'transfers' | 'transactions'>('balances')
const [balancePage, setBalancePage] = useState(1)
const [transferPage, setTransferPage] = useState(1)
const [transactionPage, setTransactionPage] = useState(1)
const pageSize = 8
const loadAddressInfo = useCallback(async () => {
try {
@@ -137,6 +161,46 @@ export default function AddressDetailPage() {
}
}, [])
useEffect(() => {
let active = true
const tokenAddresses = [
...(addressInfo?.token_contract?.address ? [addressInfo.token_contract.address] : []),
...tokenBalances.map((balance) => balance.token_address),
...tokenTransfers.map((transfer) => transfer.token_address),
].filter((candidate, index, values): candidate is string => typeof candidate === 'string' && candidate.trim().length > 0 && values.indexOf(candidate) === index)
tokenAggregationApi.getTokensByAddressSafe(chainId, tokenAddresses).then(({ data }) => {
if (!active) return
setTokenMarkets(Object.fromEntries(data.map((snapshot) => [snapshot.address.toLowerCase(), snapshot])))
}).catch(() => {
if (active) {
setTokenMarkets({})
}
})
return () => {
active = false
}
}, [addressInfo?.token_contract?.address, chainId, tokenBalances, tokenTransfers])
useEffect(() => {
let active = true
getNativeAssetMarketSafe(chainId).then(({ data }) => {
if (!active) return
setNativeAssetPriceUsd(data?.market?.priceUsd)
}).catch(() => {
if (active) {
setNativeAssetPriceUsd(undefined)
}
})
return () => {
active = false
}
}, [chainId])
const watchlistAddress = normalizeWatchlistAddress(addressInfo?.address || address)
const isSavedToWatchlist = watchlistAddress
? isWatchlistEntry(watchlistEntries, watchlistAddress)
@@ -272,7 +336,17 @@ export default function AddressDetailPage() {
},
{
header: 'Value',
accessor: (tx: TransactionSummary) => formatWeiAsEth(tx.value),
accessor: (tx: TransactionSummary) => {
const nativeValueUsd = estimateNativeUsdValue(tx.value, nativeAssetPriceUsd)
return (
<div className="space-y-1 text-sm">
<div>{formatWeiAsEth(tx.value)}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{nativeValueUsd != null ? `Current USD: ${formatUsd(nativeValueUsd)}` : 'Current USD unavailable'}
</div>
</div>
)
},
},
{
header: 'Status',
@@ -327,6 +401,20 @@ export default function AddressDetailPage() {
: 'N/A'
),
},
{
header: 'Current Price',
accessor: (balance: AddressTokenBalance) => {
const market = tokenMarkets[balance.token_address.toLowerCase()]?.market
return (
<div className="space-y-1 text-sm">
<div>{formatUsd(market?.priceUsd)}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
Liq. {formatUsd(market?.liquidityUsd)}
</div>
</div>
)
},
},
]
const tokenTransferColumns = [
@@ -344,7 +432,7 @@ export default function AddressDetailPage() {
{gruMetadata ? <EntityBadge label="GRU" tone="success" /> : null}
{gruMetadata?.x402Ready ? <EntityBadge label="x402 ready" tone="info" /> : null}
{gruMetadata?.iso20022Ready ? <EntityBadge label="ISO-20022" tone="info" /> : null}
{gruMetadata?.transportActiveVersion ? <EntityBadge label={`transport ${gruMetadata.transportActiveVersion}`} tone="warning" /> : null}
{gruMetadata?.transportActiveVersion ? <EntityBadge label={`cW public-network ${gruMetadata.transportActiveVersion}`} tone="warning" /> : null}
</div>
{transfer.token_address && (
<Link href={`/tokens/${transfer.token_address}`} className="text-primary-600 hover:underline">
@@ -383,6 +471,20 @@ export default function AddressDetailPage() {
header: 'When',
accessor: (transfer: AddressTokenTransfer) => formatTimestamp(transfer.timestamp),
},
{
header: 'Current Price',
accessor: (transfer: AddressTokenTransfer) => {
const market = tokenMarkets[transfer.token_address.toLowerCase()]?.market
return (
<div className="space-y-1 text-sm">
<div>{formatUsd(market?.priceUsd)}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
Liq. {formatUsd(market?.liquidityUsd)}
</div>
</div>
)
},
},
]
const incomingTransactions = transactions.filter(
@@ -403,6 +505,36 @@ export default function AddressDetailPage() {
const gruTransferCount = tokenTransfers.filter((transfer) =>
Boolean(getGruExplorerMetadata({ address: transfer.token_address, symbol: transfer.token_symbol })),
).length
const nativeAssetSymbol = getNativeAssetDescriptor(chainId).symbol
const nativeBalanceUsd = estimateNativeUsdValue(addressInfo?.balance, nativeAssetPriceUsd)
const tabs: SectionTab<typeof activeTab>[] = [
...(addressInfo?.is_contract ? [{ id: 'contract' as const, label: 'Contract' }] : []),
{ id: 'balances', label: 'Balances', count: tokenBalances.length },
{ id: 'transfers', label: 'Transfers', count: tokenTransfers.length },
{ id: 'transactions', label: 'Transactions', count: transactions.length },
]
const balancePageCount = Math.max(1, Math.ceil(tokenBalances.length / pageSize))
const transferPageCount = Math.max(1, Math.ceil(tokenTransfers.length / pageSize))
const transactionPageCount = Math.max(1, Math.ceil(transactions.length / pageSize))
const pagedTokenBalances = useMemo(
() => tokenBalances.slice((balancePage - 1) * pageSize, balancePage * pageSize),
[balancePage, tokenBalances],
)
const pagedTokenTransfers = useMemo(
() => tokenTransfers.slice((transferPage - 1) * pageSize, transferPage * pageSize),
[transferPage, tokenTransfers],
)
const pagedTransactions = useMemo(
() => transactions.slice((transactionPage - 1) * pageSize, transactionPage * pageSize),
[transactionPage, transactions],
)
useEffect(() => {
setBalancePage(1)
setTransferPage(1)
setTransactionPage(1)
setActiveTab(addressInfo?.is_contract ? 'contract' : 'balances')
}, [address, addressInfo?.is_contract])
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
@@ -473,8 +605,14 @@ export default function AddressDetailPage() {
<Address address={addressInfo.address} />
</DetailRow>
{addressInfo.balance && (
<DetailRow label="Coin Balance">{formatWeiAsEth(addressInfo.balance)}</DetailRow>
<DetailRow label="Coin Balance">
{formatWeiAsEth(addressInfo.balance)}
{nativeBalanceUsd != null ? ` (${formatUsd(nativeBalanceUsd)})` : ''}
</DetailRow>
)}
<DetailRow label="Current Native Asset Price">
{nativeAssetPriceUsd != null ? `${formatUsd(nativeAssetPriceUsd)} per ${nativeAssetSymbol}` : 'Unavailable'}
</DetailRow>
<DetailRow label="Watchlist">
{isSavedToWatchlist ? 'Saved for quick access' : 'Not saved yet'}
</DetailRow>
@@ -531,7 +669,13 @@ export default function AddressDetailPage() {
</dl>
</Card>
{addressInfo.is_contract && (
{addressInfo.is_contract ? (
<ContractVerificationCallout address={addressInfo.address} verified={Boolean(addressInfo.is_verified)} />
) : null}
<SectionTabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} className="mb-6" />
{activeTab === 'contract' && addressInfo.is_contract && (
<Card title="Contract Profile" className="mb-6">
<dl className="space-y-4">
<DetailRow label="Interaction Surface">
@@ -601,20 +745,6 @@ export default function AddressDetailPage() {
</code>
</DetailRow>
)}
{contractProfile?.source_code_preview && (
<DetailRow label="Source Preview">
<code className="block max-h-56 overflow-auto whitespace-pre-wrap break-words rounded bg-gray-50 p-2 text-xs dark:bg-gray-950">
{contractProfile.source_code_preview}
</code>
</DetailRow>
)}
{contractProfile?.abi && (
<DetailRow label="ABI Preview">
<code className="block max-h-56 overflow-auto whitespace-pre-wrap break-words rounded bg-gray-50 p-2 text-xs dark:bg-gray-950">
{contractProfile.abi}
</code>
</DetailRow>
)}
{contractProfile?.read_methods && contractProfile.read_methods.length > 0 && (
<DetailRow label="Read Methods">
<div className="space-y-3">
@@ -760,9 +890,13 @@ export default function AddressDetailPage() {
</Card>
)}
{gruProfile ? <div className="mb-6"><GruStandardsCard profile={gruProfile} /></div> : null}
{activeTab === 'contract' && addressInfo.is_contract && contractProfile ? (
<ContractCodeWorkspace address={addressInfo.address} profile={contractProfile} />
) : null}
<Card title="Token Balances" className="mb-6">
{activeTab === 'contract' && gruProfile ? <div className="mb-6"><GruStandardsCard profile={gruProfile} /></div> : null}
{activeTab === 'balances' ? <Card title="Token Balances" className="mb-6">
{gruBalanceCount > 0 ? (
<div className="mb-4 flex flex-wrap items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
<span>{gruBalanceCount} visible token balance{gruBalanceCount === 1 ? '' : 's'} look GRU-aware.</span>
@@ -773,13 +907,19 @@ export default function AddressDetailPage() {
) : null}
<Table
columns={tokenBalanceColumns}
data={tokenBalances}
data={pagedTokenBalances}
emptyMessage="No token balances were indexed for this address."
keyExtractor={(balance) => balance.token_address || `${balance.token_symbol}-${balance.value}`}
/>
</Card>
<PaginationControls
page={balancePage}
pageCount={balancePageCount}
onPageChange={setBalancePage}
label="Token balances"
/>
</Card> : null}
<Card title="Recent Token Transfers" className="mb-6">
{activeTab === 'transfers' ? <Card title="Recent Token Transfers" className="mb-6">
{gruTransferCount > 0 ? (
<div className="mb-4 flex flex-wrap items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
<span>{gruTransferCount} recent transfer asset{gruTransferCount === 1 ? '' : 's'} carry GRU posture in the explorer.</span>
@@ -793,20 +933,32 @@ export default function AddressDetailPage() {
) : null}
<Table
columns={tokenTransferColumns}
data={tokenTransfers}
data={pagedTokenTransfers}
emptyMessage="No token transfers were found for this address."
keyExtractor={(transfer) => `${transfer.transaction_hash}-${transfer.token_address}-${transfer.value}`}
/>
</Card>
<PaginationControls
page={transferPage}
pageCount={transferPageCount}
onPageChange={setTransferPage}
label="Token transfers"
/>
</Card> : null}
<Card title="Transactions">
{activeTab === 'transactions' ? <Card title="Transactions">
<Table
columns={transactionColumns}
data={transactions}
data={pagedTransactions}
emptyMessage="No recent transactions were found for this address."
keyExtractor={(tx) => tx.hash}
/>
</Card>
<PaginationControls
page={transactionPage}
pageCount={transactionPageCount}
onPageChange={setTransactionPage}
label="Transactions"
/>
</Card> : null}
</>
)}
</div>

View File

@@ -11,7 +11,7 @@ export default function GruDocsPage() {
<PageIntro
eyebrow="Explorer Documentation"
title="GRU Guide"
description="A user-facing summary of the GRU standards, transport posture, and x402 readiness model, with concrete places to inspect those signals on live token, address, and search pages."
description="A user-facing summary of the GRU standards, bridge posture, public-network representations, and x402 readiness model, with concrete places to inspect those signals on live token, address, and search pages."
actions={[
{ href: '/tokens', label: 'Browse tokens' },
{ href: '/search?q=cUSDC', label: 'Search cUSDC' },
@@ -23,7 +23,7 @@ export default function GruDocsPage() {
<Card title="What The Explorer Is Showing You">
<div className="space-y-4 text-sm text-gray-600 dark:text-gray-400">
<p>
The explorer now distinguishes between canonical GRU money surfaces on Chain 138 and wrapped transport assets used on public-chain bridge lanes.
The explorer now distinguishes between canonical GRU surfaces on Chain 138 and cW public-network representations used on bridge lanes.
It also highlights when a token looks ready for x402-style payment flows.
</p>
<p>
@@ -49,6 +49,14 @@ export default function GruDocsPage() {
<Card title="Standards Summary">
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-xl border border-gray-200 bg-gray-50 p-4 text-sm dark:border-gray-700 dark:bg-gray-900/40 md:col-span-2">
<div className="font-medium text-gray-900 dark:text-white">Public token language</div>
<div className="mt-2 text-gray-600 dark:text-gray-400">
The explorer follows the GRU monetary policy taxonomy: <strong>c</strong> means compliant instrument created by a regulated financial entity or institution,
<strong> W</strong> means wrapped representation on a public network, <strong>XXX</strong> is the ISO-4217 currency code or ISO-style commodity code,
<strong> C</strong> marks cash-tokenized electronic money, and <strong> T</strong> marks treasury or government bond exposure.
</div>
</div>
<div className="rounded-xl border border-gray-200 bg-gray-50 p-4 text-sm dark:border-gray-700 dark:bg-gray-900/40">
<div className="font-medium text-gray-900 dark:text-white">Base token profile</div>
<div className="mt-2 text-gray-600 dark:text-gray-400">
@@ -95,7 +103,7 @@ export default function GruDocsPage() {
<Card title="Chain 138 Practical Reading">
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-400">
<p>
A token can be forward-canonical and x402-ready even while older liquidity or transport lanes still run on a prior version.
A token can be forward-canonical and x402-ready even while older liquidity or bridge lanes still run on a prior version.
That is why the explorer separates active liquidity posture from forward-canonical posture.
</p>
<p>

View File

@@ -8,7 +8,7 @@ const docsCards = [
{
title: 'GRU Guide',
href: '/docs/gru',
description: 'Understand GRU standards, x402 readiness, wrapped transport posture, and forward-canonical versioning as surfaced by the explorer.',
description: 'Understand GRU standards, x402 readiness, cW public-network posture, and forward-canonical versioning as surfaced by the explorer.',
},
{
title: 'Transaction Evidence Matrix',
@@ -88,8 +88,8 @@ export default function DocsIndexPage() {
<Card title="Operator & Domains">
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-400">
<p>
SolaceScan is the public Chain 138 explorer operated by DBIS / Defi Oracle. The explorer may be reached through
<code> blockscout.defi-oracle.io</code> or <code> explorer.d-bis.org</code>.
DBIS Explorer is the public Chain 138 explorer operated by DBIS. Primary public access is served at
<code> explorer.d-bis.org</code>; <code> blockscout.defi-oracle.io</code> is the Blockscout companion domain.
</p>
<p>
These domains are part of the same explorer and companion-tooling surface, including the Snap install path at

View File

@@ -12,7 +12,7 @@ export default function HomeAliasPage() {
return (
<main className="container mx-auto px-4 py-12">
<div className="mx-auto max-w-xl rounded-xl border border-gray-200 bg-white p-6 text-center shadow-sm dark:border-gray-700 dark:bg-gray-800">
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">Redirecting to SolaceScan</h1>
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">Redirecting to DBIS Explorer</h1>
<p className="mt-3 text-sm leading-7 text-gray-600 dark:text-gray-400">
The legacy <code className="rounded bg-gray-100 px-1 py-0.5 text-xs dark:bg-gray-900">/home</code> route now redirects to the main explorer landing page.
</p>

View File

@@ -7,6 +7,7 @@ import type { MissionControlBridgeStatusResponse } from '@/services/api/missionC
import type { ExplorerStats } from '@/services/api/stats'
import { fetchPublicJson } from '@/utils/publicExplorer'
import { fetchExplorerTruthContext } from '@/utils/serverExplorerContext'
import { loadTokenListResponseForSurface } from '@/services/api/tokenListSurfaces'
interface TokenPoolRecord {
symbol: string
@@ -46,7 +47,7 @@ export default function LiquidityPage(props: LiquidityPageProps) {
export const getServerSideProps: GetServerSideProps<LiquidityPageProps> = async () => {
const [tokenListResult, routeMatrixResult, plannerCapabilitiesResult, internalPlanResult, truthContext] =
await Promise.all([
fetchPublicJson<TokenListResponse>('/api/config/token-list').catch(() => null),
loadTokenListResponseForSurface('extended', 138).then((value) => value.response).catch(() => null),
fetchPublicJson<RouteMatrixResponse>('/token-aggregation/api/v1/routes/matrix?includeNonLive=true').catch(() => null),
fetchPublicJson<PlannerCapabilitiesResponse>('/token-aggregation/api/v2/providers/capabilities?chainId=138').catch(
() => null,

View File

@@ -6,6 +6,7 @@ import type { CapabilitiesResponse, NetworksConfigResponse, TokenListResponse }
import type { ExplorerStats } from '@/services/api/stats'
import { fetchPublicJson } from '@/utils/publicExplorer'
import { fetchExplorerTruthContext } from '@/utils/serverExplorerContext'
import { loadTokenListResponseForSurface } from '@/services/api/tokenListSurfaces'
interface OperationsPageProps {
initialBridgeStatus: MissionControlBridgeStatusResponse | null
@@ -24,7 +25,7 @@ export const getStaticProps: GetStaticProps<OperationsPageProps> = async () => {
const [routesResult, networksResult, tokenListResult, capabilitiesResult, truthContext] = await Promise.all([
fetchPublicJson<RouteMatrixResponse>('/token-aggregation/api/v1/routes/matrix?includeNonLive=true').catch(() => null),
fetchPublicJson<NetworksConfigResponse>('/api/config/networks').catch(() => null),
fetchPublicJson<TokenListResponse>('/api/config/token-list').catch(() => null),
loadTokenListResponseForSurface('extended', 138).then((value) => value.response).catch(() => null),
fetchPublicJson<CapabilitiesResponse>('/api/config/capabilities').catch(() => null),
fetchExplorerTruthContext(),
])

View File

@@ -3,7 +3,9 @@ import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/router'
import { Card, Address } from '@/libs/frontend-ui-primitives'
import Link from 'next/link'
import { configApi, type TokenListToken } from '@/services/api/config'
import type { TokenListToken } from '@/services/api/config'
import { tokensApi } from '@/services/api/tokens'
import { tokenAggregationApi, type TokenAggregationTokenSnapshot } from '@/services/api/tokenAggregation'
import EntityBadge from '@/components/common/EntityBadge'
import {
inferDirectSearchTarget,
@@ -14,7 +16,9 @@ import {
} from '@/utils/search'
import PageIntro from '@/components/common/PageIntro'
import { fetchPublicJson } from '@/utils/publicExplorer'
import { fetchTokenListForSurface } from '@/services/api/tokenListSurfaces'
import { useUiMode } from '@/components/common/UiModeContext'
import MarketEvidenceNote from '@/components/common/MarketEvidenceNote'
type SearchFilterMode = 'all' | 'gru' | 'x402' | 'wrapped'
@@ -24,6 +28,15 @@ interface SearchPageProps {
initialCuratedTokens: TokenListToken[]
}
function formatUsd(value: number | undefined): string {
if (value == null || !Number.isFinite(value)) return 'Unavailable'
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: value >= 100 ? 0 : 2,
}).format(value)
}
export default function SearchPage({
initialQuery,
initialRawResults,
@@ -40,6 +53,7 @@ export default function SearchPage({
const [curatedTokens, setCuratedTokens] = useState<TokenListToken[]>(initialCuratedTokens)
const [savedQueries, setSavedQueries] = useState<string[]>([])
const [filterMode, setFilterMode] = useState<SearchFilterMode>('all')
const [tokenMarkets, setTokenMarkets] = useState<Record<string, TokenAggregationTokenSnapshot>>({})
const runSearch = async (rawQuery: string) => {
const trimmedQuery = rawQuery.trim()
@@ -80,9 +94,9 @@ export default function SearchPage({
}
let active = true
configApi.getTokenList().then((response) => {
tokensApi.listForSurface('catalog', 138).then(({ ok, data }) => {
if (active) {
setCuratedTokens((response.tokens || []).filter((token) => token.chainId === 138))
setCuratedTokens(ok ? data : [])
}
}).catch(() => {
if (active) {
@@ -190,6 +204,27 @@ export default function SearchPage({
{ label: 'Other', items: groupedResults.other },
]
useEffect(() => {
let active = true
const tokenAddresses = filteredResults
.filter((result) => result.type === 'token' && typeof result.data.address === 'string' && result.data.address.trim().length > 0)
.map((result) => result.data.address as string)
tokenAggregationApi.getTokensByAddressSafe(138, tokenAddresses).then(({ data }) => {
if (!active) return
setTokenMarkets(Object.fromEntries(data.map((snapshot) => [snapshot.address.toLowerCase(), snapshot])))
}).catch(() => {
if (active) {
setTokenMarkets({})
}
})
return () => {
active = false
}
}, [filteredResults])
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
<PageIntro
@@ -341,30 +376,46 @@ export default function SearchPage({
</Link>
)}
{(result.type === 'address' || result.type === 'token') && result.data.address && (
<Link
href={result.href || (result.type === 'token' ? `/tokens/${result.data.address}` : `/addresses/${result.data.address}`)}
className="inline-flex flex-col gap-2 text-primary-600 hover:underline"
>
<div className="flex flex-wrap items-center gap-2">
<EntityBadge
label={result.type === 'token' ? 'token' : 'address'}
tone={result.type === 'token' ? 'success' : 'neutral'}
/>
{result.symbol && <EntityBadge label={result.symbol} tone="info" />}
{result.token_type && <EntityBadge label={result.token_type} tone="warning" />}
{result.is_curated_token && <EntityBadge label="listed" tone="success" />}
{result.is_gru_token && <EntityBadge label="GRU" tone="success" />}
{result.is_x402_ready && <EntityBadge label="x402 ready" tone="info" />}
{result.is_wrapped_transport && <EntityBadge label="wrapped" tone="warning" />}
{result.currency_code ? <EntityBadge label={result.currency_code} tone="neutral" /> : null}
{result.match_reason ? <EntityBadge label={result.match_reason} tone="info" className="normal-case tracking-normal" /> : null}
{result.matched_tags?.map((tag) => <EntityBadge key={tag} label={tag} />)}
</div>
<span className="font-medium text-gray-900 dark:text-white">
{result.name || result.symbol || result.label}
</span>
<Address address={result.data.address} truncate showCopy={false} />
</Link>
(() => {
const market = result.type === 'token'
? tokenMarkets[result.data.address.toLowerCase()]?.market
: null
return (
<Link
href={result.href || (result.type === 'token' ? `/tokens/${result.data.address}` : `/addresses/${result.data.address}`)}
className="inline-flex flex-col gap-2 text-primary-600 hover:underline"
>
<div className="flex flex-wrap items-center gap-2">
<EntityBadge
label={result.type === 'token' ? 'token' : 'address'}
tone={result.type === 'token' ? 'success' : 'neutral'}
/>
{result.symbol && <EntityBadge label={result.symbol} tone="info" />}
{result.token_type && <EntityBadge label={result.token_type} tone="warning" />}
{result.is_curated_token && <EntityBadge label="listed" tone="success" />}
{result.is_gru_token && <EntityBadge label="GRU" tone="success" />}
{result.is_x402_ready && <EntityBadge label="x402 ready" tone="info" />}
{result.is_wrapped_transport && <EntityBadge label="cW public-network" tone="warning" />}
{result.currency_code ? <EntityBadge label={result.currency_code} tone="neutral" /> : null}
{result.match_reason ? <EntityBadge label={result.match_reason} tone="info" className="normal-case tracking-normal" /> : null}
{result.matched_tags?.map((tag) => <EntityBadge key={tag} label={tag} />)}
</div>
<span className="font-medium text-gray-900 dark:text-white">
{result.name || result.symbol || result.label}
</span>
<Address address={result.data.address} truncate showCopy={false} />
{market ? (
<div className="text-sm text-gray-500 dark:text-gray-400">
<div className="flex flex-wrap gap-x-3 gap-y-1">
<span>Live price: {formatUsd(market.priceUsd)}</span>
<span>Visible liquidity: {formatUsd(market.liquidityUsd)}</span>
</div>
<MarketEvidenceNote lastUpdated={market.lastUpdated} compact />
</div>
) : null}
</Link>
)
})()
)}
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-sm text-gray-500">
<span>Type: {result.type}</span>
@@ -442,10 +493,7 @@ export default function SearchPage({
export const getServerSideProps: GetServerSideProps<SearchPageProps> = async (context) => {
const initialQuery = typeof context.query.q === 'string' ? context.query.q.trim() : ''
const tokenListResult = await fetchPublicJson<{ tokens?: TokenListToken[] }>('/api/config/token-list').catch(() => null)
const initialCuratedTokens = Array.isArray(tokenListResult?.tokens)
? tokenListResult.tokens.filter((token) => token.chainId === 138)
: []
const { tokens: initialCuratedTokens } = await fetchTokenListForSurface('catalog', 138)
const shouldFetchSearch =
Boolean(initialQuery) &&

View File

@@ -1,6 +1,7 @@
import type { GetServerSideProps } from 'next'
import SystemOperationsPage from '@/components/explorer/SystemOperationsPage'
import { fetchPublicJson } from '@/utils/publicExplorer'
import { loadTokenListResponseForSurface } from '@/services/api/tokenListSurfaces'
import type { CapabilitiesResponse, NetworksConfigResponse, TokenListResponse } from '@/services/api/config'
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
import type { RouteMatrixResponse } from '@/services/api/routes'
@@ -23,7 +24,7 @@ export const getServerSideProps: GetServerSideProps<SystemPageProps> = async ()
const [bridgeStatus, networksConfig, tokenList, capabilities, routeMatrix, stats] = await Promise.all([
fetchPublicJson<MissionControlBridgeStatusResponse>('/explorer-api/v1/track1/bridge/status').catch(() => null),
fetchPublicJson<NetworksConfigResponse>('/api/config/networks').catch(() => null),
fetchPublicJson<TokenListResponse>('/api/config/token-list').catch(() => null),
loadTokenListResponseForSurface('extended', 138).then((value) => value.response).catch(() => null),
fetchPublicJson<CapabilitiesResponse>('/api/config/capabilities').catch(() => null),
fetchPublicJson<RouteMatrixResponse>('/token-aggregation/api/v1/routes/matrix?includeNonLive=true').catch(() => null),
fetchPublicJson('/api/v2/stats').then((value) => normalizeExplorerStats(value as never)).catch(() => null),

View File

@@ -11,9 +11,14 @@ import PageIntro from '@/components/common/PageIntro'
import { DetailRow } from '@/components/common/DetailRow'
import EntityBadge from '@/components/common/EntityBadge'
import GruStandardsCard from '@/components/common/GruStandardsCard'
import TokenSigningSurfaceCard from '@/components/common/TokenSigningSurfaceCard'
import MarketEvidenceNote from '@/components/common/MarketEvidenceNote'
import PaginationControls from '@/components/common/PaginationControls'
import SectionTabs, { type SectionTab } from '@/components/common/SectionTabs'
import { formatTokenAmount, formatTimestamp } from '@/utils/format'
import { getGruStandardsProfileSafe, type GruStandardsProfile } from '@/services/api/gru'
import { getGruExplorerMetadata } from '@/services/api/gruExplorerData'
import { contractsApi, type ContractProfile } from '@/services/api/contracts'
function isValidAddress(value: string) {
return /^0x[a-fA-F0-9]{40}$/.test(value)
@@ -49,17 +54,24 @@ export default function TokenDetailPage() {
const [transfers, setTransfers] = useState<AddressTokenTransfer[]>([])
const [pools, setPools] = useState<MissionControlLiquidityPool[]>([])
const [gruProfile, setGruProfile] = useState<GruStandardsProfile | null>(null)
const [contractProfile, setContractProfile] = useState<ContractProfile | null>(null)
const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState<'intelligence' | 'standards' | 'holders' | 'transfers' | 'liquidity'>('intelligence')
const [holderPage, setHolderPage] = useState(1)
const [transferPage, setTransferPage] = useState(1)
const [poolPage, setPoolPage] = useState(1)
const pageSize = 8
const loadToken = useCallback(async () => {
setLoading(true)
try {
const [tokenResult, provenanceResult, holdersResult, transfersResult, poolsResult] = await Promise.all([
const [tokenResult, provenanceResult, holdersResult, transfersResult, poolsResult, contractResult] = await Promise.all([
tokensApi.getSafe(address),
tokensApi.getProvenanceSafe(address),
tokensApi.getHoldersSafe(address, 1, 10),
tokensApi.getTransfersSafe(address, 1, 10),
tokensApi.getRelatedPoolsSafe(address),
contractsApi.getProfileSafe(address),
])
setToken(tokenResult.ok ? tokenResult.data : null)
@@ -67,11 +79,14 @@ export default function TokenDetailPage() {
setHolders(holdersResult.ok ? holdersResult.data : [])
setTransfers(transfersResult.ok ? transfersResult.data : [])
setPools(poolsResult.ok ? poolsResult.data : [])
const resolvedContractProfile = contractResult.ok ? contractResult.data : null
setContractProfile(resolvedContractProfile)
if (tokenResult.ok && tokenResult.data) {
const gruResult = await getGruStandardsProfileSafe({
address,
symbol: tokenResult.data.symbol,
tags: provenanceResult.ok ? provenanceResult.data?.tags || [] : [],
contractProfile: resolvedContractProfile,
})
setGruProfile(gruResult.ok ? gruResult.data : null)
} else {
@@ -84,6 +99,7 @@ export default function TokenDetailPage() {
setTransfers([])
setPools([])
setGruProfile(null)
setContractProfile(null)
} finally {
setLoading(false)
}
@@ -97,6 +113,7 @@ export default function TokenDetailPage() {
if (!isValidTokenAddress) {
setLoading(false)
setToken(null)
setContractProfile(null)
return
}
void loadToken()
@@ -176,6 +193,35 @@ export default function TokenDetailPage() {
() => getGruExplorerMetadata({ address: token?.address || address, symbol: token?.symbol }),
[address, token?.address, token?.symbol],
)
const tabs: SectionTab<typeof activeTab>[] = [
{ id: 'intelligence', label: 'Intelligence' },
...(gruProfile || gruExplorerMetadata ? [{ id: 'standards' as const, label: 'Standards' }] : []),
{ id: 'holders', label: 'Holders', count: holders.length },
{ id: 'transfers', label: 'Transfers', count: transfers.length },
{ id: 'liquidity', label: 'Liquidity', count: pools.length },
]
const holderPageCount = Math.max(1, Math.ceil(holders.length / pageSize))
const transferPageCount = Math.max(1, Math.ceil(transfers.length / pageSize))
const poolPageCount = Math.max(1, Math.ceil(pools.length / pageSize))
const pagedHolders = useMemo(
() => holders.slice((holderPage - 1) * pageSize, holderPage * pageSize),
[holderPage, holders],
)
const pagedTransfers = useMemo(
() => transfers.slice((transferPage - 1) * pageSize, transferPage * pageSize),
[transferPage, transfers],
)
const pagedPools = useMemo(
() => pools.slice((poolPage - 1) * pageSize, poolPage * pageSize),
[poolPage, pools],
)
useEffect(() => {
setActiveTab('intelligence')
setHolderPage(1)
setTransferPage(1)
setPoolPage(1)
}, [address])
const holderColumns = [
{
@@ -273,7 +319,7 @@ export default function TokenDetailPage() {
<div className="container mx-auto px-4 py-6 sm:py-8">
<PageIntro
eyebrow="Token Detail"
title={token?.symbol || token?.name || 'Token'}
title={token?.symbol || token?.name || provenance?.symbol || provenance?.name || 'Token'}
description="Inspect token supply, holders, transfers, and liquidity context with the sort of composure one normally has to borrow from a better explorer."
actions={[
{ href: '/tokens', label: 'Token index' },
@@ -302,15 +348,20 @@ export default function TokenDetailPage() {
<p className="text-sm text-gray-600 dark:text-gray-400">Invalid token address. Please use a full 42-character 0x-prefixed address.</p>
</Card>
) : !token ? (
<Card>
<p className="text-sm text-gray-600 dark:text-gray-400">Token details were not found for this address.</p>
</Card>
<div className="space-y-6">
<Card>
<p className="text-sm text-gray-600 dark:text-gray-400">
Token details were not found for this address from the token index APIs. If this is a contract, verification metadata and ERC-5267 may still be available below.
</p>
</Card>
<TokenSigningSurfaceCard address={address} contractProfile={contractProfile} />
</div>
) : (
<div className="space-y-6">
<Card title="Token Overview">
<dl className="space-y-4">
<DetailRow label="Name">{token.name || 'Unknown'}</DetailRow>
<DetailRow label="Symbol">{token.symbol || 'Unknown'}</DetailRow>
<DetailRow label="Name">{token.name || provenance?.name || 'Unknown'}</DetailRow>
<DetailRow label="Symbol">{token.symbol || provenance?.symbol || 'Unknown'}</DetailRow>
<DetailRow label="Address">
<Address address={token.address} />
</DetailRow>
@@ -341,14 +392,26 @@ export default function TokenDetailPage() {
</dl>
</Card>
<Card title="Token Intelligence">
<TokenSigningSurfaceCard address={token.address} contractProfile={contractProfile} />
<SectionTabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} className="mb-6" />
{activeTab === 'intelligence' ? <Card title="Token Intelligence">
<div className="grid gap-4 lg:grid-cols-2">
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Market Context</div>
<div className="mt-3 space-y-2 text-sm text-gray-700 dark:text-gray-300">
<div>Indicative price: {formatUsd(token.exchange_rate)}</div>
<div>Current price: {formatUsd(token.exchange_rate)}</div>
<div>24h volume: {formatUsd(token.volume_24h)}</div>
<div>Market cap: {formatUsd(token.circulating_market_cap)}</div>
<div>Visible liquidity: {formatUsd(token.liquidity_usd)}</div>
<div>Valuation source: {token.price_source === 'token-aggregation' ? 'live token aggregation' : token.price_source || 'unavailable'}</div>
<div>Market snapshot: {token.market_updated_at ? formatTimestamp(token.market_updated_at) : 'Unavailable'}</div>
<MarketEvidenceNote
source={token.price_source}
lastUpdated={token.market_updated_at}
method="Merged Blockscout token profile with token aggregation price, volume, and visible-liquidity fields where available."
/>
</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
@@ -378,11 +441,11 @@ export default function TokenDetailPage() {
</div>
</div>
</div>
</Card>
</Card> : null}
{gruProfile ? <GruStandardsCard profile={gruProfile} /> : null}
{activeTab === 'standards' && gruProfile ? <GruStandardsCard profile={gruProfile} /> : null}
{gruExplorerMetadata ? (
{activeTab === 'standards' && gruExplorerMetadata ? (
<Card title="x402 And ISO-20022 Posture">
<div className="grid gap-4 lg:grid-cols-2">
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
@@ -425,11 +488,11 @@ export default function TokenDetailPage() {
</Card>
) : null}
{gruExplorerMetadata && gruExplorerMetadata.otherNetworks.length > 0 ? (
{activeTab === 'standards' && gruExplorerMetadata && gruExplorerMetadata.otherNetworks.length > 0 ? (
<Card title="Other Networks">
<div className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
These are sibling representations or settlement counterparts for the same GRU asset family on other networks, drawn from the local transport and mapping posture used by this workspace.
These are sibling representations or settlement counterparts for the same GRU asset family on other networks, drawn from the local public-network overlay and mapping posture used by this workspace.
</p>
<div className="space-y-3">
{gruExplorerMetadata.otherNetworks.map((network) => (
@@ -458,16 +521,18 @@ export default function TokenDetailPage() {
</Card>
) : null}
<Card title="Top Holders">
{activeTab === 'holders' ? <Card title="Top Holders">
<Table
layout="tabular"
columns={holderColumns}
data={holders}
data={pagedHolders}
emptyMessage="No holder data was available for this token."
keyExtractor={(holder) => holder.address}
/>
</Card>
<PaginationControls page={holderPage} pageCount={holderPageCount} onPageChange={setHolderPage} label="Holders" />
</Card> : null}
<Card title="Recent Transfers">
{activeTab === 'transfers' ? <Card title="Recent Transfers">
{gruExplorerMetadata ? (
<div className="mb-4 flex flex-wrap items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
<span>
@@ -483,20 +548,22 @@ export default function TokenDetailPage() {
) : null}
<Table
columns={transferColumns}
data={transfers}
data={pagedTransfers}
emptyMessage="No recent token transfers were available."
keyExtractor={(transfer) => `${transfer.transaction_hash}-${transfer.value}-${transfer.from_address}`}
/>
</Card>
<PaginationControls page={transferPage} pageCount={transferPageCount} onPageChange={setTransferPage} label="Transfers" />
</Card> : null}
<Card title="Related Liquidity">
{activeTab === 'liquidity' ? <Card title="Related Liquidity">
<Table
columns={poolColumns}
data={pools}
data={pagedPools}
emptyMessage="No related liquidity pools were exposed through mission control for this token."
keyExtractor={(pool) => pool.address}
/>
</Card>
<PaginationControls page={poolPage} pageCount={poolPageCount} onPageChange={setPoolPage} label="Pools" />
</Card> : null}
</div>
)}
</div>

View File

@@ -5,13 +5,16 @@ import { useEffect, useMemo, useState } from 'react'
import { Card } from '@/libs/frontend-ui-primitives'
import PageIntro from '@/components/common/PageIntro'
import EntityBadge from '@/components/common/EntityBadge'
import MarketEvidenceNote from '@/components/common/MarketEvidenceNote'
import { tokensApi } from '@/services/api/tokens'
import type { TokenListToken } from '@/services/api/config'
import { fetchPublicJson } from '@/utils/publicExplorer'
import { tokenAggregationApi, type TokenAggregationTokenSnapshot } from '@/services/api/tokenAggregation'
import { fetchTokenListForSurface, TOKEN_LIST_SURFACE_LABELS } from '@/services/api/tokenListSurfaces'
import { selectCuratedFeaturedTokens } from '@/utils/featuredTokens'
const quickSearches = [
{ label: 'cUSDT', description: 'Canonical bridged USDT liquidity and address results.' },
{ label: 'cUSDC', description: 'Canonical bridged USDC routes and address coverage.' },
{ label: 'cUSDT', description: 'Canonical compliant USD treasury / government bond liquidity and address results.' },
{ label: 'cUSDC', description: 'Canonical compliant USD cash electronic-money routes and address coverage.' },
{ label: 'cXAUC', description: 'Gold-backed cXAUC pools and token references.' },
{ label: 'cXAUT', description: 'Gold-backed cXAUT references and search coverage.' },
{ label: 'cEURT', description: 'EUR liquidity and cXAUC-connected route coverage.' },
@@ -27,10 +30,33 @@ interface TokensPageProps {
initialCuratedTokens: TokenListToken[]
}
function formatUsd(value: number | undefined): string {
if (value == null || !Number.isFinite(value)) return 'Unavailable'
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: value >= 100 ? 0 : 2,
}).format(value)
}
function tagPriority(tag: string) {
const order: Record<string, number> = {
gru: 0,
compliant: 1,
'treasury-bond': 2,
'electronic-money': 2,
commodity: 2,
'reference-asset': 0,
defi: 4,
}
return order[tag] ?? 3
}
export default function TokensPage({ initialCuratedTokens }: TokensPageProps) {
const router = useRouter()
const [query, setQuery] = useState('')
const [curatedTokens, setCuratedTokens] = useState<TokenListToken[]>(initialCuratedTokens)
const [featuredMarkets, setFeaturedMarkets] = useState<Record<string, TokenAggregationTokenSnapshot>>({})
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault()
@@ -45,7 +71,7 @@ export default function TokensPage({ initialCuratedTokens }: TokensPageProps) {
}
let active = true
tokensApi.listCuratedSafe(138).then(({ ok, data }) => {
tokensApi.listForSurface('catalog', 138).then(({ ok, data }) => {
if (active) {
setCuratedTokens(ok ? data : [])
}
@@ -59,21 +85,39 @@ export default function TokensPage({ initialCuratedTokens }: TokensPageProps) {
}
}, [initialCuratedTokens])
const featuredCuratedTokens = useMemo(() => {
const preferred = ['cUSDT', 'cUSDC', 'cXAUC', 'cXAUT', 'cEURT', 'USDT']
const selected = preferred
.map((symbol) => curatedTokens.find((token) => token.symbol === symbol))
.filter((token): token is TokenListToken => Boolean(token?.address))
const featuredCuratedTokens = useMemo(
() => selectCuratedFeaturedTokens(curatedTokens) as TokenListToken[],
[curatedTokens],
)
return selected.length > 0 ? selected : curatedTokens.slice(0, 6)
}, [curatedTokens])
useEffect(() => {
let active = true
const featuredAddresses = featuredCuratedTokens
.map((token) => token.address)
.filter((address): address is string => typeof address === 'string' && address.trim().length > 0)
tokenAggregationApi.getTokensByAddressSafe(138, featuredAddresses).then(({ data }) => {
if (!active) return
const next = Object.fromEntries(data.map((snapshot) => [snapshot.address.toLowerCase(), snapshot]))
setFeaturedMarkets(next)
}).catch(() => {
if (active) {
setFeaturedMarkets({})
}
})
return () => {
active = false
}
}, [featuredCuratedTokens])
return (
<div className="container mx-auto px-4 py-8">
<PageIntro
eyebrow="Token Discovery"
title="Tokens"
description="Browse curated Chain 138 assets, open token contracts directly, and move into holders, transfers, liquidity, and provenance without pretending a search box is a complete token strategy."
description="Browse the canonical Chain 138 trading set, open token contracts directly, and review holders, transfers, liquidity, and provenance from the same institutional explorer surface."
actions={[
{ href: '/wallet', label: 'Wallet tools' },
{ href: '/liquidity', label: 'Liquidity access' },
@@ -81,98 +125,89 @@ export default function TokensPage({ initialCuratedTokens }: TokensPageProps) {
]}
/>
<Card className="mb-6" title="Find A Token">
<form onSubmit={handleSubmit} className="flex flex-col gap-3 md:flex-row">
<Card className="mb-5" title="Find a token">
<form onSubmit={handleSubmit} className="flex flex-col gap-2 sm:flex-row">
<input
type="text"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Token symbol, name, or contract address"
className="flex-1 rounded-lg border border-gray-300 px-4 py-2 focus:outline-none focus:ring-2 focus:ring-primary-500"
className="min-h-10 flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 dark:border-gray-700 dark:bg-gray-950"
/>
<button
type="submit"
disabled={!query.trim()}
className="rounded-lg bg-primary-600 px-6 py-2 text-white hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-50"
className="min-h-10 rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-50"
>
Search
</button>
</form>
<p className="mt-3 text-sm text-gray-600 dark:text-gray-400">
Contract addresses open dedicated token detail pages with holders, transfers, provenance, and liquidity context.
</p>
</Card>
<div className="grid gap-6 lg:grid-cols-3">
<Card title="Curated Registry">
<p className="text-sm text-gray-600 dark:text-gray-400">
Review listed Chain 138 assets with provenance tags such as compliant, wrapped, and bridge-aware before acting on a symbol match.
</p>
<div className="mt-4">
<Link href="/tokens" className="text-primary-600 hover:underline">
Browse curated tokens
</Link>
</div>
</Card>
<Card title="Wallet Discovery">
<p className="text-sm text-gray-600 dark:text-gray-400">
Add Chain 138 and supported token metadata to MetaMask directly from the explorer wallet tools.
</p>
<div className="mt-4">
<Link href="/wallet" className="text-primary-600 hover:underline">
Open wallet tools
</Link>
</div>
</Card>
<Card title="Liquidity Routes">
<p className="text-sm text-gray-600 dark:text-gray-400">
Review canonical PMM routes, partner payload templates, and token-routing examples for supported pools.
</p>
<div className="mt-4">
<Link href="/liquidity" className="text-primary-600 hover:underline">
Open liquidity access
</Link>
</div>
</Card>
</div>
<div className="mt-8">
<div className="mt-5">
<Card title="Curated Chain 138 tokens">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{featuredCuratedTokens.map((token) => (
<Link
key={token.address}
href={`/tokens/${token.address}`}
className="rounded-lg border border-gray-200 p-4 transition hover:border-primary-400 hover:shadow-sm dark:border-gray-700"
>
<div className="text-sm font-semibold text-gray-900 dark:text-white">{token.symbol || token.name || 'Token'}</div>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
{token.name || 'Listed in the Chain 138 token registry.'}
</p>
{token.tags && token.tags.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{token.tags.slice(0, 3).map((tag) => (
<EntityBadge key={`${token.address}-${tag}`} label={tag} className="px-2 py-1 text-[11px]" />
))}
</div>
)}
</Link>
))}
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
{TOKEN_LIST_SURFACE_LABELS.catalog}. Showing {featuredCuratedTokens.length} featured tokens from the live report list.
</p>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{featuredCuratedTokens
.filter((token): token is TokenListToken & { address: string } => typeof token.address === 'string' && token.address.trim().length > 0)
.map((token) => {
const market = featuredMarkets[token.address.toLowerCase()]?.market
return (
<Link
key={token.address}
href={`/tokens/${token.address}`}
className="rounded-lg border border-gray-200 p-4 transition hover:border-primary-400 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-950/50"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-gray-900 dark:text-white">{token.symbol || token.name || 'Token'}</div>
<p className="mt-1 line-clamp-2 text-sm leading-5 text-gray-600 dark:text-gray-400">
{token.name || 'Listed in the Chain 138 token registry.'}
</p>
</div>
</div>
{market ? (
<div className="mt-3 grid grid-cols-2 gap-2 text-sm text-gray-700 dark:text-gray-300">
<div className="rounded-lg bg-gray-50 px-3 py-2 dark:bg-gray-950/60">
<div className="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Price</div>
<div className="mt-1 font-semibold text-gray-950 dark:text-white">{formatUsd(market.priceUsd)}</div>
</div>
<div className="rounded-lg bg-gray-50 px-3 py-2 dark:bg-gray-950/60">
<div className="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Liquidity</div>
<div className="mt-1 font-semibold text-gray-950 dark:text-white">{formatUsd(market.liquidityUsd)}</div>
</div>
<div className="col-span-2">
<MarketEvidenceNote lastUpdated={market.lastUpdated} compact />
</div>
</div>
) : null}
{token.tags && token.tags.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{[...token.tags].sort((a, b) => tagPriority(a) - tagPriority(b)).slice(0, 3).map((tag) => (
<EntityBadge key={`${token.address}-${tag}`} label={tag} className="px-2 py-1 text-[11px]" />
))}
</div>
)}
</Link>
)
})}
</div>
</Card>
</div>
<div className="mt-8">
<div className="mt-5">
<Card title="Common token searches">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{quickSearches.map((token) => (
<Link
key={token.label}
href={`/search?q=${encodeURIComponent(token.label)}`}
className="rounded-lg border border-gray-200 p-4 transition hover:border-primary-400 hover:shadow-sm dark:border-gray-700"
className="rounded-lg border border-gray-200 px-3 py-2 transition hover:border-primary-400 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-950/50"
>
<div className="text-sm font-semibold text-gray-900 dark:text-white">{token.label}</div>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">{token.description}</p>
<p className="mt-1 line-clamp-2 text-xs leading-5 text-gray-600 dark:text-gray-400">{token.description}</p>
</Link>
))}
</div>
@@ -183,15 +218,11 @@ export default function TokensPage({ initialCuratedTokens }: TokensPageProps) {
}
export const getStaticProps: GetStaticProps<TokensPageProps> = async () => {
const tokenListResult = await fetchPublicJson<{ tokens?: TokenListToken[] }>('/api/config/token-list').catch(() => null)
const { tokens } = await fetchTokenListForSurface('catalog', 138)
return {
props: {
initialCuratedTokens: Array.isArray(tokenListResult?.tokens)
? tokenListResult.tokens
.filter((token) => token.chainId === 138 && typeof token.address === 'string' && token.address.trim().length > 0)
.sort((left, right) => (left.symbol || left.name || '').localeCompare(right.symbol || right.name || ''))
: [],
initialCuratedTokens: tokens,
},
revalidate: 300,
}

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/router'
import { Card, Address, Table } from '@/libs/frontend-ui-primitives'
import Link from 'next/link'
@@ -15,13 +15,55 @@ import { formatTimestamp, formatTokenAmount, formatWeiAsEth } from '@/utils/form
import { DetailRow } from '@/components/common/DetailRow'
import EntityBadge from '@/components/common/EntityBadge'
import PageIntro from '@/components/common/PageIntro'
import PaginationControls from '@/components/common/PaginationControls'
import SectionTabs, { type SectionTab } from '@/components/common/SectionTabs'
import { getGruCatalogPosture } from '@/services/api/gruCatalog'
import { assessTransactionCompliance } from '@/utils/transactionCompliance'
import {
tokenAggregationApi,
type CheckpointTxAttestationSnapshot,
type TokenAggregationHistoricalPriceSnapshot,
} from '@/services/api/tokenAggregation'
import { estimateNativeUsdValue, estimateTokenUsdValue, getNativeAssetDescriptor, getNativeAssetPriceAtSafe } from '@/services/api/nativeAssetPricing'
function isValidTransactionHash(value: string) {
return /^0x[a-fA-F0-9]{64}$/.test(value)
}
function formatUsd(value: string | number | undefined): string {
if (value == null) return 'Unavailable'
const numeric = typeof value === 'number' ? value : Number(value)
if (!Number.isFinite(numeric)) return 'Unavailable'
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: numeric >= 100 ? 0 : 2,
}).format(numeric)
}
function formatHistoricalPriceSource(source: string | undefined): string {
switch (source) {
case 'ohlcv_5m':
return 'historical OHLCV 5m'
case 'ohlcv_15m':
return 'historical OHLCV 15m'
case 'ohlcv_1h':
return 'historical OHLCV 1h'
case 'ohlcv_4h':
return 'historical OHLCV 4h'
case 'ohlcv_24h':
return 'historical OHLCV 24h'
case 'coingecko_history':
return 'historical CoinGecko market'
case 'current_market_fallback':
return 'current market fallback'
case 'canonical_fallback':
return 'canonical fallback'
default:
return 'unavailable'
}
}
export default function TransactionDetailPage() {
const router = useRouter()
const hash = typeof router.query.hash === 'string' ? router.query.hash : ''
@@ -31,7 +73,14 @@ export default function TransactionDetailPage() {
const [transaction, setTransaction] = useState<Transaction | null>(null)
const [internalCalls, setInternalCalls] = useState<TransactionInternalCall[]>([])
const [diagnostic, setDiagnostic] = useState<TransactionLookupDiagnostic | null>(null)
const [historicalTokenPrices, setHistoricalTokenPrices] = useState<Record<string, TokenAggregationHistoricalPriceSnapshot>>({})
const [historicalNativePrice, setHistoricalNativePrice] = useState<TokenAggregationHistoricalPriceSnapshot | null>(null)
const [checkpointAttestation, setCheckpointAttestation] = useState<CheckpointTxAttestationSnapshot | null>(null)
const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState<'evidence' | 'details' | 'transfers' | 'internal' | 'raw'>('evidence')
const [transferPage, setTransferPage] = useState(1)
const [internalPage, setInternalPage] = useState(1)
const pageSize = 8
const loadTransaction = useCallback(async () => {
setLoading(true)
@@ -91,6 +140,81 @@ export default function TransactionDetailPage() {
loadTransaction()
}, [hash, isValidHash, loadTransaction, router.isReady])
useEffect(() => {
let active = true
const tokenAddresses = (transaction?.token_transfers || [])
.map((transfer) => transfer.token_address)
.filter((candidate, index, values): candidate is string => typeof candidate === 'string' && candidate.trim().length > 0 && values.indexOf(candidate) === index)
if (!transaction?.created_at || tokenAddresses.length === 0) {
setHistoricalTokenPrices({})
return () => {
active = false
}
}
Promise.all(tokenAddresses.map((address) => tokenAggregationApi.getPriceAtSafe(chainId, address, transaction.created_at))).then((results) => {
if (!active) return
const snapshots = results
.filter((result): result is { ok: true; data: TokenAggregationHistoricalPriceSnapshot | null } => result.ok)
.map((result) => result.data)
.filter((snapshot): snapshot is TokenAggregationHistoricalPriceSnapshot => Boolean(snapshot?.tokenAddress))
setHistoricalTokenPrices(Object.fromEntries(snapshots.map((snapshot) => [snapshot.tokenAddress.toLowerCase(), snapshot])))
}).catch(() => {
if (active) {
setHistoricalTokenPrices({})
}
})
return () => {
active = false
}
}, [chainId, transaction?.created_at, transaction?.token_transfers])
useEffect(() => {
let active = true
if (!transaction?.created_at) {
setHistoricalNativePrice(null)
return () => {
active = false
}
}
getNativeAssetPriceAtSafe(chainId, transaction.created_at).then(({ data }) => {
if (!active) return
setHistoricalNativePrice(data)
}).catch(() => {
if (active) {
setHistoricalNativePrice(null)
}
})
return () => {
active = false
}
}, [chainId, transaction?.created_at])
useEffect(() => {
let active = true
if (!isValidHash) {
setCheckpointAttestation(null)
return () => {
active = false
}
}
tokenAggregationApi.getCheckpointAttestationSafe(hash).then(({ ok, data }) => {
if (!active) return
setCheckpointAttestation(ok && data?.included ? data : null)
}).catch(() => {
if (active) setCheckpointAttestation(null)
})
return () => {
active = false
}
}, [hash, isValidHash])
const tokenTransferColumns = [
{
header: 'Token',
@@ -137,6 +261,32 @@ export default function TransactionDetailPage() {
formatTokenAmount(transfer.amount, transfer.token_decimals, transfer.token_symbol)
),
},
{
header: 'Transfer-Time Value',
accessor: (transfer: TransactionTokenTransfer) => {
const want = transfer.token_address.toLowerCase()
const checkpointLine = checkpointAttestation?.leaf?.transfers?.find((line) => {
const addr = (line.tokenAddress || line.token || '').toLowerCase()
return addr === want
})
const historicalPrice = historicalTokenPrices[transfer.token_address.toLowerCase()]
const totalUsd = checkpointLine?.valueUsd != null
? Number(checkpointLine.valueUsd)
: estimateTokenUsdValue(transfer.amount, transfer.token_decimals, historicalPrice?.priceUsd)
const priceSource = checkpointLine?.priceSource ?? historicalPrice?.source
return (
<div className="space-y-1 text-sm">
<div>{totalUsd != null && Number.isFinite(totalUsd) ? formatUsd(totalUsd) : 'Unavailable'}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
Unit price: {checkpointLine?.valueUsd != null ? 'checkpoint leaf' : formatUsd(historicalPrice?.priceUsd)}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
Source: {checkpointLine ? `checkpoint (${priceSource || 'enriched'})` : formatHistoricalPriceSource(priceSource)}
</div>
</div>
)
},
},
]
const internalCallColumns = [
@@ -186,6 +336,15 @@ export default function TransactionDetailPage() {
: null
const tokenTransferCount = transaction?.token_transfers?.length || 0
const internalCallCount = internalCalls.length
const nativeAssetSymbol = getNativeAssetDescriptor(chainId).symbol
const checkpointLeaf = checkpointAttestation?.leaf
const checkpointNativeUsd = checkpointLeaf?.nativeValueUsd ?? checkpointLeaf?.valueUsd
const parsedCheckpointNativeUsd = checkpointNativeUsd != null ? Number(checkpointNativeUsd) : null
const nativeValueUsd = parsedCheckpointNativeUsd != null && Number.isFinite(parsedCheckpointNativeUsd)
? parsedCheckpointNativeUsd
: estimateNativeUsdValue(transaction?.value, historicalNativePrice?.priceUsd)
const nativeFeeUsd = estimateNativeUsdValue(transaction?.fee, historicalNativePrice?.priceUsd)
const checkpointTotalUsd = checkpointLeaf?.totalTransfersUsd ?? checkpointLeaf?.valueUsd
const complianceAssessment = transaction
? assessTransactionCompliance({
transaction,
@@ -193,6 +352,29 @@ export default function TransactionDetailPage() {
tokenTransfers: transaction.token_transfers || [],
})
: null
const tabs: SectionTab<typeof activeTab>[] = [
{ id: 'evidence', label: 'Evidence' },
{ id: 'details', label: 'Details' },
{ id: 'transfers', label: 'Transfers', count: tokenTransferCount },
{ id: 'internal', label: 'Internal', count: internalCallCount },
...(transaction?.input_data ? [{ id: 'raw' as const, label: 'Raw input' }] : []),
]
const transferPageCount = Math.max(1, Math.ceil((transaction?.token_transfers?.length || 0) / pageSize))
const internalPageCount = Math.max(1, Math.ceil(internalCalls.length / pageSize))
const pagedTokenTransfers = useMemo(
() => (transaction?.token_transfers || []).slice((transferPage - 1) * pageSize, transferPage * pageSize),
[transaction?.token_transfers, transferPage],
)
const pagedInternalCalls = useMemo(
() => internalCalls.slice((internalPage - 1) * pageSize, internalPage * pageSize),
[internalCalls, internalPage],
)
useEffect(() => {
setActiveTab('evidence')
setTransferPage(1)
setInternalPage(1)
}, [hash])
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
@@ -275,7 +457,10 @@ export default function TransactionDetailPage() {
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Gas & Fees</div>
<div className="mt-3 space-y-2 text-sm text-gray-700 dark:text-gray-300">
<div>Fee: {transaction.fee ? formatWeiAsEth(transaction.fee) : 'Unavailable'}</div>
<div>
Fee: {transaction.fee ? formatWeiAsEth(transaction.fee) : 'Unavailable'}
{nativeFeeUsd != null ? ` (${formatUsd(nativeFeeUsd)})` : ''}
</div>
<div>Gas used: {transaction.gas_used != null ? transaction.gas_used.toLocaleString() : 'Unavailable'}</div>
<div>Utilization: {gasUtilization != null ? `${gasUtilization}%` : 'Unavailable'}</div>
</div>
@@ -283,7 +468,18 @@ export default function TransactionDetailPage() {
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Value Movement</div>
<div className="mt-3 space-y-2 text-sm text-gray-700 dark:text-gray-300">
<div>Native value: {formatWeiAsEth(transaction.value)}</div>
<div>
Native value: {formatWeiAsEth(transaction.value)}
{nativeValueUsd != null ? ` (${formatUsd(nativeValueUsd)})` : ''}
</div>
<div>Transfer-time native price: {historicalNativePrice?.priceUsd != null ? `${formatUsd(historicalNativePrice.priceUsd)} per ${nativeAssetSymbol}` : 'Unavailable'}</div>
<div>Pricing source: {checkpointLeaf ? `checkpoint mirror (${checkpointLeaf.priceSource || 'enriched'})` : formatHistoricalPriceSource(historicalNativePrice?.source)}</div>
{checkpointAttestation ? (
<div>
Checkpoint batch #{checkpointAttestation.batchId}
{checkpointTotalUsd != null ? ` — total ${formatUsd(Number(checkpointTotalUsd))}` : ''}
</div>
) : null}
<div>Token transfers: {tokenTransferCount.toLocaleString()}</div>
<div>Internal calls: {internalCallCount.toLocaleString()}</div>
</div>
@@ -307,7 +503,9 @@ export default function TransactionDetailPage() {
</div>
</Card>
{complianceAssessment ? (
<SectionTabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} className="mb-6" />
{activeTab === 'evidence' && complianceAssessment ? (
<Card title="Transaction Evidence Matrix">
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
@@ -335,7 +533,7 @@ export default function TransactionDetailPage() {
</Card>
) : null}
<Card title="Transaction Information">
{activeTab === 'details' ? <Card title="Transaction Information">
<dl className="space-y-4">
<DetailRow label="Hash">
<Address address={transaction.hash} />
@@ -368,8 +566,16 @@ export default function TransactionDetailPage() {
</Link>
</DetailRow>
)}
<DetailRow label="Value">{formatWeiAsEth(transaction.value)}</DetailRow>
{transaction.fee && <DetailRow label="Fee">{formatWeiAsEth(transaction.fee)}</DetailRow>}
<DetailRow label="Value">
{formatWeiAsEth(transaction.value)}
{nativeValueUsd != null ? ` (${formatUsd(nativeValueUsd)})` : ''}
</DetailRow>
{transaction.fee && (
<DetailRow label="Fee">
{formatWeiAsEth(transaction.fee)}
{nativeFeeUsd != null ? ` (${formatUsd(nativeFeeUsd)})` : ''}
</DetailRow>
)}
<DetailRow label="Gas Price">
{transaction.gas_price != null ? `${transaction.gas_price / 1e9} Gwei` : 'N/A'}
</DetailRow>
@@ -391,9 +597,9 @@ export default function TransactionDetailPage() {
</DetailRow>
)}
</dl>
</Card>
</Card> : null}
{transaction.decoded_input && transaction.decoded_input.parameters.length > 0 && (
{activeTab === 'details' && transaction.decoded_input && transaction.decoded_input.parameters.length > 0 && (
<Card title="Decoded Input">
<div className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
@@ -422,25 +628,27 @@ export default function TransactionDetailPage() {
</Card>
)}
<Card title="Token Transfers">
{activeTab === 'transfers' ? <Card title="Token Transfers">
<Table
columns={tokenTransferColumns}
data={transaction.token_transfers || []}
data={pagedTokenTransfers}
emptyMessage="No token transfers were indexed for this transaction."
keyExtractor={(transfer) => `${transfer.token_address}-${transfer.from_address}-${transfer.to_address}-${transfer.amount}`}
/>
</Card>
<PaginationControls page={transferPage} pageCount={transferPageCount} onPageChange={setTransferPage} label="Token transfers" />
</Card> : null}
<Card title="Internal Transactions">
{activeTab === 'internal' ? <Card title="Internal Transactions">
<Table
columns={internalCallColumns}
data={internalCalls}
data={pagedInternalCalls}
emptyMessage="No internal transactions were exposed for this transaction."
keyExtractor={(call) => `${call.from_address}-${call.to_address || call.contract_address || 'unknown'}-${call.value}-${call.type || 'call'}`}
/>
</Card>
<PaginationControls page={internalPage} pageCount={internalPageCount} onPageChange={setInternalPage} label="Internal calls" />
</Card> : null}
{transaction.input_data && (
{activeTab === 'raw' && transaction.input_data && (
<Card title="Raw Input Data">
<pre className="overflow-x-auto whitespace-pre-wrap break-all rounded-lg bg-gray-50 p-4 text-xs text-gray-800 dark:bg-gray-950 dark:text-gray-200">
{transaction.input_data}

View File

@@ -24,7 +24,7 @@ export default function WalletRoutePage(props: WalletRoutePageProps) {
export const getServerSideProps: GetServerSideProps<WalletRoutePageProps> = async () => {
const [networksResult, tokenListResult, capabilitiesResult] = await Promise.all([
fetchPublicJsonWithMeta<NetworksCatalog>('/api/config/networks').catch(() => null),
fetchPublicJsonWithMeta<TokenListCatalog>('/api/config/token-list').catch(() => null),
fetchPublicJsonWithMeta<TokenListCatalog>('/api/v1/report/token-list?chainId=138').catch(() => null),
fetchPublicJsonWithMeta<CapabilitiesCatalog>('/api/config/capabilities').catch(() => null),
])

View File

@@ -21,12 +21,33 @@ export interface AccessSession {
expires_at: string
}
export type InstitutionalTier =
| 'sovereign_central_bank'
| 'global_family_office'
| 'settlement_member'
| 'infrastructure_operator'
| 'oversight_judicial'
| 'delegated_authority'
| 'standards_body'
export const institutionalTierLabels: Record<InstitutionalTier, string> = {
sovereign_central_bank: 'Sovereign Central Bank',
global_family_office: 'Global Family Office',
settlement_member: 'Settlement Member',
infrastructure_operator: 'Infrastructure Operator',
oversight_judicial: 'Oversight & Judicial',
delegated_authority: 'Delegated Authority',
standards_body: 'Standards Body',
}
export interface WalletAccessSession {
token: string
expiresAt: string
track: string
permissions: string[]
address: string
institutionalTier?: InstitutionalTier
institutionName?: string
}
export interface AccessProduct {
@@ -145,8 +166,9 @@ function setStoredWalletSession(session: WalletAccessSession | null) {
window.dispatchEvent(new Event(ACCESS_SESSION_EVENT))
}
// Keep in sync with walletAuthSignMessage() in backend/auth/wallet_auth.go.
function buildWalletMessage(nonce: string) {
return `Sign this message to authenticate with SolaceScan.\n\nNonce: ${nonce}`
return `Sign this message to authenticate with DBIS Explorer.\n\nNonce: ${nonce}`
}
async function fetchWalletJson<T>(path: string, init?: RequestInit): Promise<T> {
@@ -220,6 +242,8 @@ export const accessApi = {
expires_at: string
track: string
permissions: string[]
institutional_tier?: InstitutionalTier
institution_name?: string
}>(`${ACCESS_API_PREFIX}/auth/wallet`, {
method: 'POST',
body: JSON.stringify({ address, signature, nonce }),
@@ -230,6 +254,8 @@ export const accessApi = {
track: response.track,
permissions: response.permissions || [],
address,
institutionalTier: response.institutional_tier,
institutionName: response.institution_name,
}
setStoredWalletSession(session)
return session

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import { normalizeBridgeRouteEntries, type BridgeRouteCatalog } from './bridgeRoutes'
describe('bridgeRoutesApi helpers', () => {
it('normalizes WETH9 and WETH10 route tables', () => {
const routes: BridgeRouteCatalog = {
weth9: {
'Ethereum Mainnet (1)': '0x1111111111111111111111111111111111111111',
},
weth10: {
'Base (8453)': '0x2222222222222222222222222222222222222222',
},
}
expect(normalizeBridgeRouteEntries(routes)).toEqual([
{
bridge: 'WETH10',
destination: 'Base (8453)',
address: '0x2222222222222222222222222222222222222222',
},
{
bridge: 'WETH9',
destination: 'Ethereum Mainnet (1)',
address: '0x1111111111111111111111111111111111111111',
},
])
})
})

Some files were not shown because too many files have changed in this diff Show More