Ship bridge lanes, public API access doc, and WalletConnect client stack.
Align CCIP catalog UX with 11-lane config-ready routes, document the no-key public API decision, and enable browser WalletConnect pairing with backend session registration and deploy-time project ID wiring. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -49,6 +50,31 @@ func (s *Server) handleWalletConnectConnect(w http.ResponseWriter, r *http.Reque
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// handleWalletConnectSessionRegister handles POST /api/v1/walletconnect/session
|
||||
func (s *Server) handleWalletConnectSessionRegister(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
Address string `json:"address"`
|
||||
ChainID int `json:"chainId"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
|
||||
return
|
||||
}
|
||||
|
||||
session, err := s.walletConnectHandler().RegisterSession(r.Context(), req.SessionID, req.Address, req.ChainID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, session)
|
||||
}
|
||||
|
||||
// handleWalletConnectSession handles GET /api/v1/walletconnect/session/{id}
|
||||
func (s *Server) handleWalletConnectSession(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
@@ -65,7 +91,7 @@ func (s *Server) handleWalletConnectSession(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
session, err := s.walletConnectHandler().GetSession(r.Context(), sessionID)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotImplemented, session)
|
||||
writeJSON(w, http.StatusNotFound, session)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, session)
|
||||
@@ -83,6 +109,8 @@ func (s *Server) handleWalletConnectRoot(w http.ResponseWriter, r *http.Request)
|
||||
s.handleWalletConnectMetadata(w, r)
|
||||
case "connect":
|
||||
s.handleWalletConnectConnect(w, r)
|
||||
case "session":
|
||||
s.handleWalletConnectSessionRegister(w, r)
|
||||
case "":
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
||||
@@ -93,6 +121,7 @@ func (s *Server) handleWalletConnectRoot(w http.ResponseWriter, r *http.Request)
|
||||
"/api/v1/walletconnect/config",
|
||||
"/api/v1/walletconnect/metadata",
|
||||
"/api/v1/walletconnect/connect",
|
||||
"POST /api/v1/walletconnect/session",
|
||||
"/api/v1/walletconnect/session/{sessionId}",
|
||||
},
|
||||
"fallbackAuth": "/api/v1/auth/wallet",
|
||||
|
||||
@@ -33,7 +33,8 @@ func TestHandleWalletConnectConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleWalletConnectConnectStub(t *testing.T) {
|
||||
func TestHandleWalletConnectConnectDisabled(t *testing.T) {
|
||||
t.Setenv("WALLETCONNECT_PROJECT_ID", "")
|
||||
server := NewServer(nil, 138)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/walletconnect/connect", strings.NewReader("{}"))
|
||||
@@ -43,25 +44,58 @@ func TestHandleWalletConnectConnectStub(t *testing.T) {
|
||||
if rec.Code != http.StatusNotImplemented {
|
||||
t.Fatalf("expected 501, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleWalletConnectConnectClientMode(t *testing.T) {
|
||||
t.Setenv("WALLETCONNECT_PROJECT_ID", "test-project-id")
|
||||
server := NewServer(nil, 138)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/walletconnect/connect", strings.NewReader("{}"))
|
||||
rec := httptest.NewRecorder()
|
||||
server.handleWalletConnectConnect(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rec.Code)
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if payload["status"] != "stub" {
|
||||
t.Fatalf("expected stub status, got %#v", payload["status"])
|
||||
if payload["status"] != "client" {
|
||||
t.Fatalf("expected client status, got %#v", payload["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleWalletConnectSessionStub(t *testing.T) {
|
||||
func TestHandleWalletConnectSessionMissing(t *testing.T) {
|
||||
server := NewServer(nil, 138)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/walletconnect/session/demo-session", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
server.handleWalletConnectSession(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotImplemented {
|
||||
t.Fatalf("expected 501, got %d", rec.Code)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleWalletConnectSessionRegister(t *testing.T) {
|
||||
server := NewServer(nil, 138)
|
||||
|
||||
body := strings.NewReader(`{"sessionId":"wc-demo","address":"0x4A666F96fC8764181194447A7dFdb7d471b301C8","chainId":138}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/walletconnect/session", body)
|
||||
rec := httptest.NewRecorder()
|
||||
server.handleWalletConnectSessionRegister(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
getReq := httptest.NewRequest(http.MethodGet, "/api/v1/walletconnect/session/wc-demo", nil)
|
||||
getRec := httptest.NewRecorder()
|
||||
server.handleWalletConnectSession(getRec, getReq)
|
||||
if getRec.Code != http.StatusOK {
|
||||
t.Fatalf("expected lookup 200, got %d", getRec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
const (
|
||||
WalletConnectStatusStub = "stub"
|
||||
WalletConnectStatusClient = "client"
|
||||
WalletConnectStatusDisabled = "disabled"
|
||||
)
|
||||
|
||||
@@ -75,9 +76,9 @@ func (wc *WalletConnect) enabled() bool {
|
||||
|
||||
// PublicConfig returns the read-only WalletConnect config surface for clients.
|
||||
func (wc *WalletConnect) PublicConfig() Config {
|
||||
status := WalletConnectStatusStub
|
||||
if !wc.enabled() {
|
||||
status = WalletConnectStatusDisabled
|
||||
status := WalletConnectStatusDisabled
|
||||
if wc.enabled() {
|
||||
status = WalletConnectStatusClient
|
||||
}
|
||||
return Config{
|
||||
Status: status,
|
||||
@@ -95,30 +96,55 @@ func (wc *WalletConnect) PublicConfig() Config {
|
||||
|
||||
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 enabled. Use the WalletConnect button on /wallet for mobile QR pairing; browser extension wallets can continue using /api/v1/auth/wallet."
|
||||
}
|
||||
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.
|
||||
// Connect reports client-side WalletConnect posture. Pairing runs in the browser when projectId is published.
|
||||
func (wc *WalletConnect) Connect(_ context.Context) (*ConnectResponse, error) {
|
||||
if !wc.enabled() {
|
||||
return &ConnectResponse{
|
||||
Status: WalletConnectStatusDisabled,
|
||||
Enabled: false,
|
||||
FallbackAuth: "/api/v1/auth/wallet",
|
||||
Message: wc.publicMessage(),
|
||||
}, fmt.Errorf("walletconnect is disabled")
|
||||
}
|
||||
return &ConnectResponse{
|
||||
Status: WalletConnectStatusStub,
|
||||
Enabled: wc.enabled(),
|
||||
Status: "client",
|
||||
Enabled: true,
|
||||
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")
|
||||
Message: "Initialize WalletConnect in the browser via /wallet using the published projectId; authenticate with /api/v1/auth/wallet after pairing.",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetSession gets a wallet session snapshot. Storage is not implemented yet.
|
||||
// GetSession returns a registered browser-paired WalletConnect session snapshot.
|
||||
func (wc *WalletConnect) GetSession(_ context.Context, sessionID string) (*Session, error) {
|
||||
if strings.TrimSpace(sessionID) == "" {
|
||||
return nil, fmt.Errorf("session id is required")
|
||||
}
|
||||
if session, ok := lookupWalletConnectSession(sessionID); ok {
|
||||
return session, nil
|
||||
}
|
||||
return &Session{
|
||||
SessionID: sessionID,
|
||||
Connected: false,
|
||||
Status: WalletConnectStatusStub,
|
||||
Message: "WalletConnect session lookup is stubbed.",
|
||||
}, fmt.Errorf("walletconnect session storage not implemented")
|
||||
Status: WalletConnectStatusClient,
|
||||
Message: "Session not registered yet. Pair on /wallet, then POST /api/v1/walletconnect/session with sessionId and address.",
|
||||
}, fmt.Errorf("walletconnect session not found")
|
||||
}
|
||||
|
||||
// RegisterSession stores a client-paired WalletConnect session for operator lookup.
|
||||
func (wc *WalletConnect) RegisterSession(_ context.Context, sessionID, address string, chainID int) (*Session, error) {
|
||||
if strings.TrimSpace(sessionID) == "" {
|
||||
return nil, fmt.Errorf("session id is required")
|
||||
}
|
||||
if !strings.HasPrefix(strings.ToLower(address), "0x") || len(address) != 42 {
|
||||
return nil, fmt.Errorf("valid address is required")
|
||||
}
|
||||
if chainID <= 0 {
|
||||
chainID = wc.chainID
|
||||
}
|
||||
return RegisterClientSession(sessionID, address, chainID), nil
|
||||
}
|
||||
|
||||
88
backend/wallet/walletconnect_sessions.go
Normal file
88
backend/wallet/walletconnect_sessions.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const walletConnectSessionTTL = 24 * time.Hour
|
||||
|
||||
type storedWalletConnectSession struct {
|
||||
SessionID string
|
||||
Address string
|
||||
ChainID int
|
||||
Connected bool
|
||||
Status string
|
||||
Message string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
walletConnectSessionMu sync.RWMutex
|
||||
walletConnectSessions = map[string]storedWalletConnectSession{}
|
||||
)
|
||||
|
||||
func purgeExpiredWalletConnectSessions(now time.Time) {
|
||||
for id, session := range walletConnectSessions {
|
||||
if now.Sub(session.UpdatedAt) > walletConnectSessionTTL {
|
||||
delete(walletConnectSessions, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterClientSession records a browser-paired WalletConnect session snapshot.
|
||||
func RegisterClientSession(sessionID, address string, chainID int) *Session {
|
||||
sessionID = strings.TrimSpace(sessionID)
|
||||
address = strings.TrimSpace(address)
|
||||
now := time.Now().UTC()
|
||||
|
||||
walletConnectSessionMu.Lock()
|
||||
defer walletConnectSessionMu.Unlock()
|
||||
purgeExpiredWalletConnectSessions(now)
|
||||
|
||||
record := storedWalletConnectSession{
|
||||
SessionID: sessionID,
|
||||
Address: address,
|
||||
ChainID: chainID,
|
||||
Connected: true,
|
||||
Status: WalletConnectStatusClient,
|
||||
Message: "WalletConnect session registered by browser client after pairing.",
|
||||
UpdatedAt: now,
|
||||
}
|
||||
walletConnectSessions[sessionID] = record
|
||||
return sessionFromStored(record)
|
||||
}
|
||||
|
||||
func lookupWalletConnectSession(sessionID string) (*Session, bool) {
|
||||
sessionID = strings.TrimSpace(sessionID)
|
||||
if sessionID == "" {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
walletConnectSessionMu.Lock()
|
||||
defer walletConnectSessionMu.Unlock()
|
||||
purgeExpiredWalletConnectSessions(now)
|
||||
|
||||
record, ok := walletConnectSessions[sessionID]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if now.Sub(record.UpdatedAt) > walletConnectSessionTTL {
|
||||
delete(walletConnectSessions, sessionID)
|
||||
return nil, false
|
||||
}
|
||||
return sessionFromStored(record), true
|
||||
}
|
||||
|
||||
func sessionFromStored(record storedWalletConnectSession) *Session {
|
||||
return &Session{
|
||||
SessionID: record.SessionID,
|
||||
Address: record.Address,
|
||||
ChainID: record.ChainID,
|
||||
Connected: record.Connected,
|
||||
Status: record.Status,
|
||||
Message: record.Message,
|
||||
}
|
||||
}
|
||||
25
backend/wallet/walletconnect_sessions_test.go
Normal file
25
backend/wallet/walletconnect_sessions_test.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRegisterAndLookupWalletConnectSession(t *testing.T) {
|
||||
sessionID := "wc-test-topic-123"
|
||||
address := "0x4A666F96fC8764181194447A7dFdb7d471b301C8"
|
||||
|
||||
registered := RegisterClientSession(sessionID, address, 138)
|
||||
if registered == nil || !registered.Connected {
|
||||
t.Fatalf("expected connected session, got %#v", registered)
|
||||
}
|
||||
|
||||
wc := NewWalletConnect(138)
|
||||
session, err := wc.GetSession(context.Background(), sessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSession: %v", err)
|
||||
}
|
||||
if session.Address != address {
|
||||
t.Fatalf("expected address %s, got %s", address, session.Address)
|
||||
}
|
||||
}
|
||||
28
docs/EXPLORER_PUBLIC_API_ACCESS.md
Normal file
28
docs/EXPLORER_PUBLIC_API_ACCESS.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Explorer public API access (decision record)
|
||||
|
||||
**Date:** 2026-05-23
|
||||
**Live page:** `/docs/public-api-access`
|
||||
|
||||
## Summary
|
||||
|
||||
| Surface | Auth today | Notes |
|
||||
|---------|------------|-------|
|
||||
| Blockscout read API (`/api/v2/*`) | None | Same-origin proxy to Blockscout |
|
||||
| Public JSON (stats, bridge routes, token lists, etc.) | None | Listed in footer **Public APIs** |
|
||||
| Managed RPC keys | Wallet session on `/access` | `POST /api/v1/access/api-keys` after `/api/v1/auth/wallet` |
|
||||
|
||||
## Decision
|
||||
|
||||
1. **Keep Blockscout and public JSON unauthenticated** for integrators on the public explorer domain.
|
||||
2. **Managed RPC keys** remain the wallet-authenticated product on `/access` — not a Blockscout API-key layer.
|
||||
3. **Future path (Option B):** nginx/API-gateway throttling with optional `X-API-Key` for higher quotas if abuse appears. Full external developer portal remains optional.
|
||||
|
||||
## Integrator flow
|
||||
|
||||
- Read-only: use footer links or `/docs/public-api-access` endpoint list.
|
||||
- Higher limits / RPC: connect wallet on `/wallet`, open `/access`, create scoped keys (tier, product, expiry, quota).
|
||||
|
||||
## Operator
|
||||
|
||||
- No nginx key gate required until rate-limit policy changes.
|
||||
- Support contact: `support@d-bis.org`
|
||||
3840
frontend/package-lock.json
generated
3840
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.14.2",
|
||||
"@walletconnect/ethereum-provider": "^2.21.10",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"axios": "^1.15.2",
|
||||
"clsx": "^2.0.0",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<link rel="canonical" href="/">
|
||||
<title>DBIS Explorer</title>
|
||||
<meta name="description" content="Redirecting to the DBIS Chain 138 explorer.">
|
||||
<!-- canonical chain138 ccipWeth9: 0xcacfd227A040002e49e2e01626363071324f820a -->
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
|
||||
@@ -472,7 +472,15 @@ export default function BridgeMonitoringPage({
|
||||
{bridgeRoutes.lastModified ? ` · updated ${relativeAge(bridgeRoutes.lastModified)}` : ''})
|
||||
</>
|
||||
) : null}
|
||||
.
|
||||
. Gnosis, Cronos, Celo, and Wemix lanes are aligned to deployed CCIP receivers — fund LINK on each remote bridge before live traffic.
|
||||
</p>
|
||||
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
Operator runbook:{' '}
|
||||
<Link href="/docs/public-api-access" className="text-primary-600 hover:underline">
|
||||
public API access
|
||||
</Link>{' '}
|
||||
· config-ready chain completion in repo{' '}
|
||||
<code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-900">CONFIG_READY_CHAINS_COMPLETION_RUNBOOK.md</code>
|
||||
</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
|
||||
@@ -7,6 +7,8 @@ import type {
|
||||
} from '@/components/wallet/AddToMetaMask'
|
||||
import { AddToMetaMask } from '@/components/wallet/AddToMetaMask'
|
||||
import WalletConnectPostureNote from '@/components/wallet/WalletConnectPostureNote'
|
||||
import { connectAndAuthenticateWalletConnect, getActiveWalletConnectSessionId, isWalletConnectClientReady, loadWalletConnectConfig } from '@/services/wallet/walletConnectClient'
|
||||
import { registerWalletConnectSession } from '@/services/api/walletConnect'
|
||||
import Link from 'next/link'
|
||||
import { Explain, useUiMode } from '@/components/common/UiModeContext'
|
||||
import { accessApi, type WalletAccessSession } from '@/services/api/access'
|
||||
@@ -48,6 +50,8 @@ export default function WalletPage(props: WalletPageProps) {
|
||||
const [walletSession, setWalletSession] = useState<WalletAccessSession | null>(null)
|
||||
const [connectingWallet, setConnectingWallet] = useState(false)
|
||||
const [walletError, setWalletError] = useState<string | null>(null)
|
||||
const [walletConnectReady, setWalletConnectReady] = useState(false)
|
||||
const [connectingWalletConnect, setConnectingWalletConnect] = useState(false)
|
||||
const [copiedAddress, setCopiedAddress] = useState(false)
|
||||
const [watchlistEntries, setWatchlistEntries] = useState<string[]>([])
|
||||
const [addressInfo, setAddressInfo] = useState<AddressInfo | null>(null)
|
||||
@@ -68,6 +72,7 @@ export default function WalletPage(props: WalletPageProps) {
|
||||
|
||||
syncSession()
|
||||
syncWatchlist()
|
||||
void loadWalletConnectConfig().then((config) => setWalletConnectReady(isWalletConnectClientReady(config)))
|
||||
window.addEventListener('explorer-access-session-changed', syncSession)
|
||||
window.addEventListener('storage', syncWatchlist)
|
||||
return () => {
|
||||
@@ -76,6 +81,30 @@ export default function WalletPage(props: WalletPageProps) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleConnectWalletConnect = async () => {
|
||||
setConnectingWalletConnect(true)
|
||||
setWalletError(null)
|
||||
try {
|
||||
const config = await loadWalletConnectConfig()
|
||||
if (!isWalletConnectClientReady(config)) {
|
||||
throw new Error('WalletConnect is not enabled. Set WALLETCONNECT_PROJECT_ID on the explorer backend.')
|
||||
}
|
||||
const result = await connectAndAuthenticateWalletConnect(config, async (resolveAddress, signMessage) => {
|
||||
const session = await accessApi.connectWalletSessionWithSigner(resolveAddress, signMessage)
|
||||
setWalletSession(session)
|
||||
return { address: session.address }
|
||||
})
|
||||
const sessionId = getActiveWalletConnectSessionId()
|
||||
if (sessionId && result.address) {
|
||||
await registerWalletConnectSession({ sessionId, address: result.address, chainId: 138 })
|
||||
}
|
||||
} catch (error) {
|
||||
setWalletError(error instanceof Error ? error.message : 'WalletConnect connection failed.')
|
||||
} finally {
|
||||
setConnectingWalletConnect(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleConnectWallet = async () => {
|
||||
setConnectingWallet(true)
|
||||
setWalletError(null)
|
||||
@@ -279,12 +308,25 @@ export default function WalletPage(props: WalletPageProps) {
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConnectWallet}
|
||||
onClick={() => void handleConnectWallet()}
|
||||
disabled={connectingWallet}
|
||||
className="rounded-lg bg-primary-600 px-3 py-2 text-sm font-semibold text-white hover:bg-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
{connectingWallet ? 'Connecting wallet…' : 'Connect wallet'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleConnectWalletConnect()}
|
||||
disabled={!walletConnectReady || connectingWalletConnect}
|
||||
title={
|
||||
walletConnectReady
|
||||
? 'Pair a mobile wallet via WalletConnect QR'
|
||||
: 'Set WALLETCONNECT_PROJECT_ID on the explorer backend to enable WalletConnect'
|
||||
}
|
||||
className="rounded-lg border border-indigo-300 px-3 py-2 text-sm font-semibold text-indigo-700 hover:bg-indigo-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:cursor-not-allowed disabled:opacity-60 dark:border-indigo-800 dark:text-indigo-300 dark:hover:bg-indigo-950/20"
|
||||
>
|
||||
{connectingWalletConnect ? 'Opening WalletConnect…' : 'WalletConnect'}
|
||||
</button>
|
||||
<Link
|
||||
href="/access"
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-900/60"
|
||||
|
||||
@@ -5,6 +5,11 @@ import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import PageIntro from '@/components/common/PageIntro'
|
||||
|
||||
const docsCards = [
|
||||
{
|
||||
title: 'Public API access',
|
||||
href: '/docs/public-api-access',
|
||||
description: 'Read-only JSON endpoints, managed RPC keys on /access, and the current no-key policy for Blockscout reads.',
|
||||
},
|
||||
{
|
||||
title: 'GRU Guide',
|
||||
href: '/docs/gru',
|
||||
|
||||
87
frontend/src/pages/docs/public-api-access.tsx
Normal file
87
frontend/src/pages/docs/public-api-access.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import PageIntro from '@/components/common/PageIntro'
|
||||
import { explorerPublicApiLinks } from '@/data/explorerOperations'
|
||||
|
||||
export default function PublicApiAccessDocsPage() {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<PageIntro
|
||||
eyebrow="Explorer Documentation"
|
||||
title="Public API access"
|
||||
description="How integrators use read-only explorer APIs today, how managed RPC keys work on /access, and the planned path if public rate limits require API keys."
|
||||
actions={[
|
||||
{ href: '/access', label: 'Account access' },
|
||||
{ href: '/wallet', label: 'Wallet tools' },
|
||||
{ href: '/operations', label: 'Operations hub' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card title="Decision summary (2026-05-23)">
|
||||
<ul className="list-disc space-y-2 pl-5 text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
<li>
|
||||
<strong>Blockscout read API</strong> (<code>/api/v2/*</code>) and the public JSON surfaces listed below remain{' '}
|
||||
<strong>unauthenticated</strong> for integrators on the public explorer domain.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Managed RPC product keys</strong> are issued through wallet-authenticated{' '}
|
||||
<Link href="/access" className="text-primary-600 hover:underline">
|
||||
Account access
|
||||
</Link>{' '}
|
||||
(Core RPC / thirdweb-rpc products). These keys gate managed RPC endpoints — not the public Blockscout read layer.
|
||||
</li>
|
||||
<li>
|
||||
If abuse or rate limits require change, the preferred near-term path is <strong>Option B</strong>: nginx/API-gateway
|
||||
throttling with optional <code>X-API-Key</code> for higher quotas. A full external developer portal remains optional.
|
||||
</li>
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<Card title="Read-only public endpoints (no key)">
|
||||
<ul className="space-y-3 text-sm">
|
||||
{explorerPublicApiLinks.map((link) => (
|
||||
<li key={link.href}>
|
||||
<a href={link.href} className="font-medium text-primary-600 hover:underline" target="_blank" rel="noopener noreferrer">
|
||||
{link.label}
|
||||
</a>
|
||||
<p className="mt-0.5 text-gray-600 dark:text-gray-400">{link.description}</p>
|
||||
</li>
|
||||
))}
|
||||
<li>
|
||||
<span className="font-medium text-gray-900 dark:text-white">Blockscout v2</span>
|
||||
<p className="mt-0.5 text-gray-600 dark:text-gray-400">
|
||||
Same-origin <code>/api/v2/stats</code>, blocks, transactions, tokens, and address endpoints proxied to Blockscout.
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<Card title="Requesting higher limits or RPC keys">
|
||||
<div className="space-y-3 text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
<p>
|
||||
For managed RPC access, connect a wallet on{' '}
|
||||
<Link href="/wallet" className="text-primary-600 hover:underline">
|
||||
Wallet tools
|
||||
</Link>{' '}
|
||||
then open{' '}
|
||||
<Link href="/access" className="text-primary-600 hover:underline">
|
||||
Account access
|
||||
</Link>{' '}
|
||||
to create scoped keys with tier, product, expiry, and optional monthly quota.
|
||||
</p>
|
||||
<p>
|
||||
For integrator questions about public JSON endpoints or future Blockscout key policy, email{' '}
|
||||
<a href="mailto:support@d-bis.org" className="text-primary-600 hover:underline">
|
||||
support@d-bis.org
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -265,21 +265,30 @@ export const accessApi = {
|
||||
throw new Error('No EOA wallet detected. Please open the explorer with a browser wallet installed.')
|
||||
}
|
||||
|
||||
const accounts = (await window.ethereum.request({
|
||||
method: 'eth_requestAccounts',
|
||||
})) as string[]
|
||||
const address = accounts?.[0]
|
||||
if (!address) {
|
||||
throw new Error('Wallet connection was cancelled.')
|
||||
}
|
||||
|
||||
return accessApi.connectWalletSessionWithSigner(async () => {
|
||||
const accounts = (await window.ethereum!.request({
|
||||
method: 'eth_requestAccounts',
|
||||
})) as string[]
|
||||
const address = accounts?.[0]
|
||||
if (!address) {
|
||||
throw new Error('Wallet connection was cancelled.')
|
||||
}
|
||||
return address
|
||||
}, async (message, address) => {
|
||||
return (await window.ethereum!.request({
|
||||
method: 'personal_sign',
|
||||
params: [message, address],
|
||||
})) as string
|
||||
})
|
||||
},
|
||||
async connectWalletSessionWithSigner(
|
||||
resolveAddress: () => Promise<string>,
|
||||
signMessage: (message: string, address: string) => Promise<string>,
|
||||
): Promise<WalletAccessSession> {
|
||||
const address = await resolveAddress()
|
||||
const nonceResponse = await accessApi.createWalletNonce(address)
|
||||
const message = buildWalletMessage(nonceResponse.nonce)
|
||||
const signature = (await window.ethereum.request({
|
||||
method: 'personal_sign',
|
||||
params: [message, address],
|
||||
})) as string
|
||||
|
||||
const signature = await signMessage(message, address)
|
||||
return accessApi.authenticateWallet(address, signature, nonceResponse.nonce)
|
||||
},
|
||||
async getMe(): Promise<{ user: AccessUser; subscriptions?: AccessSubscription[] }> {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fetchPublicJson } from '@/utils/publicExplorer'
|
||||
import { getExplorerApiBase } from './blockscout'
|
||||
|
||||
const WALLETCONNECT_CONFIG_PATH = '/explorer-api/v1/walletconnect/config'
|
||||
|
||||
@@ -22,3 +23,23 @@ export async function getWalletConnectConfig(): Promise<WalletConnectConfigRespo
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerWalletConnectSession(input: {
|
||||
sessionId: string
|
||||
address: string
|
||||
chainId?: number
|
||||
}): Promise<void> {
|
||||
const response = await fetch(`${getExplorerApiBase()}/explorer-api/v1/walletconnect/session`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sessionId: input.sessionId,
|
||||
address: input.address,
|
||||
chainId: input.chainId,
|
||||
}),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '')
|
||||
throw new Error(text || `WalletConnect session register failed (${response.status})`)
|
||||
}
|
||||
}
|
||||
|
||||
77
frontend/src/services/wallet/walletConnectClient.ts
Normal file
77
frontend/src/services/wallet/walletConnectClient.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { getWalletConnectConfig, type WalletConnectConfigResponse } from '@/services/api/walletConnect'
|
||||
|
||||
export interface WalletConnectSessionResult {
|
||||
address: string
|
||||
chainId: number
|
||||
}
|
||||
|
||||
let activeProvider: Awaited<ReturnType<typeof createEthereumProvider>> | null = null
|
||||
|
||||
async function createEthereumProvider(projectId: string, chains: number[]) {
|
||||
const { default: EthereumProvider } = await import('@walletconnect/ethereum-provider')
|
||||
const resolvedChains = (chains.length ? chains : [138, 1]) as [number, ...number[]]
|
||||
return EthereumProvider.init({
|
||||
projectId,
|
||||
chains: [resolvedChains[0]],
|
||||
optionalChains: resolvedChains,
|
||||
showQrModal: true,
|
||||
metadata: {
|
||||
name: 'DBIS Explorer',
|
||||
description: 'Chain 138 explorer by DBIS',
|
||||
url: typeof window !== 'undefined' ? window.location.origin : 'https://explorer.d-bis.org',
|
||||
icons: [`${typeof window !== 'undefined' ? window.location.origin : 'https://explorer.d-bis.org'}/favicon.ico`],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function loadWalletConnectConfig(): Promise<WalletConnectConfigResponse | null> {
|
||||
return getWalletConnectConfig()
|
||||
}
|
||||
|
||||
export function isWalletConnectClientReady(config: WalletConnectConfigResponse | null): config is WalletConnectConfigResponse {
|
||||
return Boolean(config?.enabled && config.projectId)
|
||||
}
|
||||
|
||||
export async function connectAndAuthenticateWalletConnect(
|
||||
config: WalletConnectConfigResponse,
|
||||
authenticate: (resolveAddress: () => Promise<string>, signMessage: (message: string, address: string) => Promise<string>) => Promise<{ address: string }>,
|
||||
): Promise<{ address: string }> {
|
||||
if (!config.projectId) {
|
||||
throw new Error('WalletConnect project ID is not published.')
|
||||
}
|
||||
|
||||
const chains = config.supportedChains?.length ? config.supportedChains : [138, 1]
|
||||
const provider = await createEthereumProvider(config.projectId, chains)
|
||||
activeProvider = provider
|
||||
|
||||
return authenticate(
|
||||
async () => {
|
||||
const accounts = (await provider.enable()) as string[]
|
||||
const address = accounts?.[0]
|
||||
if (!address) {
|
||||
throw new Error('WalletConnect did not return an account.')
|
||||
}
|
||||
return address
|
||||
},
|
||||
async (message, address) => {
|
||||
return (await provider.request({
|
||||
method: 'personal_sign',
|
||||
params: [message, address],
|
||||
})) as string
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function disconnectWalletConnect(): Promise<void> {
|
||||
if (!activeProvider) return
|
||||
try {
|
||||
await activeProvider.disconnect()
|
||||
} finally {
|
||||
activeProvider = null
|
||||
}
|
||||
}
|
||||
|
||||
export function getActiveWalletConnectSessionId(): string | null {
|
||||
const session = activeProvider?.session
|
||||
return typeof session?.topic === 'string' && session.topic ? session.topic : null
|
||||
}
|
||||
@@ -14,6 +14,8 @@ EXPLORER_DATABASE_URL_VALUE="${EXPLORER_DATABASE_URL_VALUE:-}"
|
||||
SECURE_AI_ENV_FILE="${SECURE_AI_ENV_FILE:-$HOME/.secure-secrets/explorer-ai.env}"
|
||||
ACCESS_ADMIN_EMAILS_VALUE="${ACCESS_ADMIN_EMAILS:-}"
|
||||
ACCESS_INTERNAL_SECRET_VALUE="${ACCESS_INTERNAL_SECRET:-}"
|
||||
WALLETCONNECT_PROJECT_ID_VALUE="${WALLETCONNECT_PROJECT_ID:-${VITE_WALLETCONNECT_PROJECT_ID:-}}"
|
||||
WALLETCONNECT_RELAY_URL_VALUE="${WALLETCONNECT_RELAY_URL:-}"
|
||||
|
||||
if [ -f "$SECURE_AI_ENV_FILE" ]; then
|
||||
set -a
|
||||
@@ -22,6 +24,12 @@ if [ -f "$SECURE_AI_ENV_FILE" ]; then
|
||||
set +a
|
||||
fi
|
||||
|
||||
if [ -z "${WALLETCONNECT_PROJECT_ID_VALUE:-}" ] && [ -f "$REPO_ROOT/smom-dbis-138/.env" ]; then
|
||||
WALLETCONNECT_PROJECT_ID_VALUE="$(grep -E '^VITE_WALLETCONNECT_PROJECT_ID=' "$REPO_ROOT/smom-dbis-138/.env" | head -1 | cut -d= -f2- | tr -d '\r\"' || true)"
|
||||
fi
|
||||
WALLETCONNECT_PROJECT_ID_VALUE="${WALLETCONNECT_PROJECT_ID:-${VITE_WALLETCONNECT_PROJECT_ID:-${WALLETCONNECT_PROJECT_ID_VALUE:-}}}"
|
||||
WALLETCONNECT_RELAY_URL_VALUE="${WALLETCONNECT_RELAY_URL:-${WALLETCONNECT_RELAY_URL_VALUE:-}}"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$TMP_DIR"
|
||||
}
|
||||
@@ -67,9 +75,11 @@ export XAI_API_KEY_VALUE="${XAI_API_KEY:-}"
|
||||
export EXPLORER_DATABASE_URL_VALUE
|
||||
export ACCESS_ADMIN_EMAILS_VALUE
|
||||
export ACCESS_INTERNAL_SECRET_VALUE
|
||||
export WALLETCONNECT_PROJECT_ID_VALUE
|
||||
export WALLETCONNECT_RELAY_URL_VALUE
|
||||
|
||||
ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@"$PROXMOX_HOST" \
|
||||
"JWT_SECRET_VALUE='$JWT_SECRET_VALUE' EXPLORER_AI_MODEL_VALUE='$EXPLORER_AI_MODEL_VALUE' XAI_API_KEY_VALUE='$XAI_API_KEY_VALUE' EXPLORER_DATABASE_URL_VALUE='$EXPLORER_DATABASE_URL_VALUE' ACCESS_ADMIN_EMAILS_VALUE='$ACCESS_ADMIN_EMAILS_VALUE' ACCESS_INTERNAL_SECRET_VALUE='$ACCESS_INTERNAL_SECRET_VALUE' bash -s" <<'REMOTE'
|
||||
"JWT_SECRET_VALUE='$JWT_SECRET_VALUE' EXPLORER_AI_MODEL_VALUE='$EXPLORER_AI_MODEL_VALUE' XAI_API_KEY_VALUE='$XAI_API_KEY_VALUE' EXPLORER_DATABASE_URL_VALUE='$EXPLORER_DATABASE_URL_VALUE' ACCESS_ADMIN_EMAILS_VALUE='$ACCESS_ADMIN_EMAILS_VALUE' ACCESS_INTERNAL_SECRET_VALUE='$ACCESS_INTERNAL_SECRET_VALUE' WALLETCONNECT_PROJECT_ID_VALUE='$WALLETCONNECT_PROJECT_ID_VALUE' WALLETCONNECT_RELAY_URL_VALUE='$WALLETCONNECT_RELAY_URL_VALUE' bash -s" <<'REMOTE'
|
||||
set -euo pipefail
|
||||
|
||||
VMID=5000
|
||||
@@ -160,6 +170,17 @@ else
|
||||
rm -f /etc/systemd/system/explorer-config-api.service.d/xai.conf
|
||||
fi
|
||||
|
||||
if [ -n "'"$WALLETCONNECT_PROJECT_ID_VALUE"'" ]; then
|
||||
cat > /etc/systemd/system/explorer-config-api.service.d/walletconnect.conf <<EOF
|
||||
[Service]
|
||||
Environment=WALLETCONNECT_PROJECT_ID='"$WALLETCONNECT_PROJECT_ID_VALUE"'
|
||||
Environment=WALLETCONNECT_RELAY_URL='"${WALLETCONNECT_RELAY_URL_VALUE:-wss://relay.walletconnect.org}"'
|
||||
EOF
|
||||
chmod 600 /etc/systemd/system/explorer-config-api.service.d/walletconnect.conf
|
||||
else
|
||||
rm -f /etc/systemd/system/explorer-config-api.service.d/walletconnect.conf
|
||||
fi
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl restart explorer-config-api
|
||||
sleep 2
|
||||
|
||||
@@ -41,6 +41,18 @@ test.describe('Explorer sprint smoke', () => {
|
||||
await page.goto(`${EXPLORER_URL}/bridge`, { waitUntil: 'domcontentloaded', timeout: 30000 })
|
||||
await expect(page.getByRole('heading', { name: /Bridge & Relay Monitoring/i })).toBeVisible({ timeout: 15000 })
|
||||
await expect(page.getByText(/CCIP route catalog/i).first()).toBeVisible({ timeout: 15000 })
|
||||
await expect(page.getByText(/Wemix/i).first()).toBeVisible({ timeout: 15000 })
|
||||
})
|
||||
|
||||
test('public API access doc page loads', async ({ page }) => {
|
||||
await page.goto(`${EXPLORER_URL}/docs/public-api-access`, { waitUntil: 'domcontentloaded', timeout: 30000 })
|
||||
await expect(page.getByRole('heading', { name: /Public API access/i })).toBeVisible({ timeout: 15000 })
|
||||
await expect(page.getByText(/Decision summary/i).first()).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
test('wallet page exposes WalletConnect action', async ({ page }) => {
|
||||
await page.goto(`${EXPLORER_URL}/wallet`, { waitUntil: 'domcontentloaded', timeout: 30000 })
|
||||
await expect(page.getByRole('button', { name: /^WalletConnect$/i })).toBeVisible({ timeout: 15000 })
|
||||
})
|
||||
|
||||
test('operations hub shows WETH in surface navigation', async ({ page }) => {
|
||||
@@ -95,6 +107,6 @@ test.describe('Explorer sprint smoke', () => {
|
||||
expect(response.ok()).toBeTruthy()
|
||||
const payload = await response.json()
|
||||
expect(payload.fallbackAuth).toBe('/api/v1/auth/wallet')
|
||||
expect(String(payload.message || '')).toMatch(/browser wallet auth/i)
|
||||
expect(String(payload.message || '')).toMatch(/auth\/wallet|WalletConnect/i)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user