Ship bridge lanes, public API access doc, and WalletConnect client stack.
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 15s
Validate Explorer / frontend (push) Failing after 20s
Validate Explorer / smoke-e2e (push) Has been skipped

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:
defiQUG
2026-05-23 02:21:37 -07:00
parent efd7c8bbcb
commit ab9c1f9f98
18 changed files with 4278 additions and 150 deletions

View File

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

View File

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

View File

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

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

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