mirror of
https://github.com/luxfi/cli.git
synced 2026-10-30 17:01:20 +00:00
Renames LUX_-prefixed env vars to canonical, non-noisy forms: - LUX_MNEMONIC -> MNEMONIC (deploy script + LLM doc) - LUX_PRIVATE_KEY -> PRIVATE_KEY (deploy script + LLM doc) - LUX_INSECURE_TLS -> INSECURE_TLS - LUX_NODE_ENDPOINT -> NODE_ENDPOINT - LUX_NETWORK_TYPE -> NETWORK_TYPE (backend gRPC server selector) - LUX_RPC_URL -> RPC_URL - LUX_NODE_ROOT -> NODE_ROOT - LUX_CHART_PATH -> CHART_PATH - LUX_CHAINS_FILE -> CHAINS_FILE - LUX_KMS_ROOT_KEY -> KMS_ROOT_KEY - LUX_WC_PROJECT_ID -> WC_PROJECT_ID (WalletConnect) - LUX_BLS_KEY -> BLS_KEY (was already supported via getEnv()) - LUX_KEY_INDEX -> KEY_INDEX (was already supported via getEnv()) pkg/key/backend_env.go: removed the LUX_-prefix-stripping getEnv() helper (now reads env vars by exact name) and dropped the broken "MNEMONIC / MNEMONIC" / "PRIVATE_KEY / PRIVATE_KEY" doc comments which were a previous incomplete rename. NOT renamed (defined in upstream luxfi/constants — separate concern): - LUX_NODE_PATH (constants.EnvNodePath) - LUX_NETRUNNER_PATH (constants.EnvNetrunnerPath) - LUX_EVM_PATH (constants.EnvEVMPath) - LUX_PLUGINS_DIR (constants.EnvPluginsDir) Doc comments referencing those env vars (cmd/root.go, cmd/networkcmd/start.go, cmd/nodecmd/link.go) describe the constants-defined names accurately and are left alone. NOT renamed (script-local variables, not env vars): - LUX_ZONE in scripts/update-cloudflare-dns.sh (Cloudflare DNS zone ID) - LUX_BIN_DIR in scripts/install.sh (~/.lux/bin path constant) BREAKING: external callers/operators must update env var names. Per CLAUDE.md no-backwards-compatibility rule, the old forms are removed.
786 lines
19 KiB
Go
786 lines
19 KiB
Go
// Copyright (C) 2022-2025, Lux Industries, Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package key
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
"github.com/skip2/go-qrcode"
|
|
)
|
|
|
|
// WalletConnect v2 protocol constants
|
|
const (
|
|
// WalletConnectName is the display name for the WalletConnect backend
|
|
WalletConnectName = "WalletConnect (Mobile Signing)"
|
|
|
|
wcRelayURL = "wss://relay.walletconnect.com"
|
|
wcProtocolID = "wc"
|
|
wcVersion = "2"
|
|
wcSessionExpiry = 7 * 24 * time.Hour // 7 days
|
|
|
|
// Request timeouts
|
|
wcPairingTimeout = 5 * time.Minute
|
|
wcSigningTimeout = 2 * time.Minute
|
|
wcConnectTimeout = 30 * time.Second
|
|
wcHeartbeatPeriod = 30 * time.Second
|
|
|
|
// Methods
|
|
wcMethodPersonalSign = "personal_sign" // EIP-191
|
|
wcMethodSignTypedV4 = "eth_signTypedData_v4" // EIP-712
|
|
wcMethodSendTx = "eth_sendTransaction"
|
|
wcMethodSignTx = "eth_signTransaction"
|
|
)
|
|
|
|
var (
|
|
ErrWCNotPaired = errors.New("walletconnect: not paired, scan QR code first")
|
|
ErrWCSessionExpired = errors.New("walletconnect: session expired")
|
|
ErrWCUserRejected = errors.New("walletconnect: user rejected request")
|
|
ErrWCTimeout = errors.New("walletconnect: request timed out")
|
|
ErrWCDisconnected = errors.New("walletconnect: disconnected from relay")
|
|
ErrWCNoProjectID = errors.New("walletconnect: project ID required (set WC_PROJECT_ID)")
|
|
ErrWCInvalidResponse = errors.New("walletconnect: invalid response from wallet")
|
|
)
|
|
|
|
// WalletConnectBackend implements remote signing via WalletConnect v2
|
|
type WalletConnectBackend struct {
|
|
dataDir string
|
|
projectID string
|
|
|
|
mu sync.RWMutex
|
|
sessions map[string]*wcSession
|
|
conn *websocket.Conn
|
|
done chan struct{}
|
|
}
|
|
|
|
// wcSession represents an active WalletConnect pairing session
|
|
type wcSession struct {
|
|
Topic string `json:"topic"`
|
|
SymKey []byte `json:"sym_key"`
|
|
PeerPubKey string `json:"peer_pub_key"`
|
|
ChainID int `json:"chain_id"`
|
|
Address string `json:"address"`
|
|
PairedAt time.Time `json:"paired_at"`
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
PeerName string `json:"peer_name"` // e.g. "MetaMask", "Rainbow"
|
|
PeerIcon string `json:"peer_icon"`
|
|
}
|
|
|
|
// wcRequest represents a JSON-RPC request to the wallet
|
|
type wcRequest struct {
|
|
ID int64 `json:"id"`
|
|
JSONRPC string `json:"jsonrpc"`
|
|
Method string `json:"method"`
|
|
Params interface{} `json:"params"`
|
|
}
|
|
|
|
// wcResponse represents a JSON-RPC response from the wallet
|
|
type wcResponse struct {
|
|
ID int64 `json:"id"`
|
|
JSONRPC string `json:"jsonrpc"`
|
|
Result json.RawMessage `json:"result,omitempty"`
|
|
Error *wcError `json:"error,omitempty"`
|
|
}
|
|
|
|
// wcError represents a JSON-RPC error
|
|
type wcError struct {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// NewWalletConnectBackend creates a new WalletConnect backend
|
|
func NewWalletConnectBackend() *WalletConnectBackend {
|
|
return &WalletConnectBackend{
|
|
sessions: make(map[string]*wcSession),
|
|
}
|
|
}
|
|
|
|
func (*WalletConnectBackend) Type() BackendType {
|
|
return BackendWalletConnect
|
|
}
|
|
|
|
func (*WalletConnectBackend) Name() string {
|
|
return WalletConnectName
|
|
}
|
|
|
|
func (*WalletConnectBackend) Available() bool {
|
|
return true // Always available as a signing option
|
|
}
|
|
|
|
func (*WalletConnectBackend) RequiresPassword() bool {
|
|
return false
|
|
}
|
|
|
|
func (*WalletConnectBackend) RequiresHardware() bool {
|
|
return false
|
|
}
|
|
|
|
func (*WalletConnectBackend) SupportsRemoteSigning() bool {
|
|
return true
|
|
}
|
|
|
|
func (b *WalletConnectBackend) Initialize(ctx context.Context) error {
|
|
// Get project ID from environment
|
|
b.projectID = os.Getenv("WC_PROJECT_ID")
|
|
if b.projectID == "" {
|
|
// WalletConnect Cloud project ID is optional but recommended
|
|
// Public fallback for development
|
|
b.projectID = "3f44137a4b2e8e5f0c4e8f9a1b2c3d4e" // Placeholder - users should set their own
|
|
}
|
|
|
|
// Set up data directory
|
|
if b.dataDir == "" {
|
|
keysDir, err := GetKeysDir()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
b.dataDir = filepath.Join(keysDir, ".walletconnect")
|
|
}
|
|
|
|
if err := os.MkdirAll(b.dataDir, 0o700); err != nil {
|
|
return fmt.Errorf("failed to create walletconnect directory: %w", err)
|
|
}
|
|
|
|
// Load existing sessions
|
|
return b.loadSessions()
|
|
}
|
|
|
|
func (b *WalletConnectBackend) Close() error {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
|
|
if b.done != nil {
|
|
close(b.done)
|
|
}
|
|
|
|
if b.conn != nil {
|
|
_ = b.conn.Close()
|
|
}
|
|
|
|
// Zero out session keys
|
|
for _, s := range b.sessions {
|
|
for i := range s.SymKey {
|
|
s.SymKey[i] = 0
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// CreateKey is not supported - WalletConnect uses external wallets
|
|
func (*WalletConnectBackend) CreateKey(_ context.Context, _ string, _ CreateKeyOptions) (*HDKeySet, error) {
|
|
return nil, errors.New("walletconnect: key creation not supported, use Pair() to connect mobile wallet")
|
|
}
|
|
|
|
// LoadKey loads session info for a paired wallet
|
|
func (b *WalletConnectBackend) LoadKey(ctx context.Context, name, password string) (*HDKeySet, error) {
|
|
b.mu.RLock()
|
|
session, ok := b.sessions[name]
|
|
b.mu.RUnlock()
|
|
|
|
if !ok {
|
|
return nil, ErrKeyNotFound
|
|
}
|
|
|
|
if time.Now().After(session.ExpiresAt) {
|
|
return nil, ErrWCSessionExpired
|
|
}
|
|
|
|
// Return minimal key set with address (no private keys - signing is remote)
|
|
return &HDKeySet{
|
|
Name: name,
|
|
ECAddress: session.Address,
|
|
}, nil
|
|
}
|
|
|
|
// SaveKey saves session info
|
|
func (b *WalletConnectBackend) SaveKey(ctx context.Context, keySet *HDKeySet, password string) error {
|
|
return b.saveSessions()
|
|
}
|
|
|
|
// DeleteKey removes a pairing session
|
|
func (b *WalletConnectBackend) DeleteKey(ctx context.Context, name string) error {
|
|
b.mu.Lock()
|
|
if s, ok := b.sessions[name]; ok {
|
|
for i := range s.SymKey {
|
|
s.SymKey[i] = 0
|
|
}
|
|
delete(b.sessions, name)
|
|
}
|
|
b.mu.Unlock()
|
|
|
|
return b.saveSessions()
|
|
}
|
|
|
|
// ListKeys returns all paired wallets
|
|
func (b *WalletConnectBackend) ListKeys(ctx context.Context) ([]KeyInfo, error) {
|
|
b.mu.RLock()
|
|
defer b.mu.RUnlock()
|
|
|
|
keys := make([]KeyInfo, 0, len(b.sessions))
|
|
for name, session := range b.sessions {
|
|
keys = append(keys, KeyInfo{
|
|
Name: name,
|
|
Address: session.Address,
|
|
Encrypted: false,
|
|
Locked: time.Now().After(session.ExpiresAt),
|
|
CreatedAt: session.PairedAt,
|
|
})
|
|
}
|
|
return keys, nil
|
|
}
|
|
|
|
func (*WalletConnectBackend) Lock(_ context.Context, _ string) error {
|
|
// No-op for WalletConnect - sessions managed externally
|
|
return nil
|
|
}
|
|
|
|
func (b *WalletConnectBackend) Unlock(ctx context.Context, name, password string) error {
|
|
// Check if session exists and is valid
|
|
b.mu.RLock()
|
|
session, ok := b.sessions[name]
|
|
b.mu.RUnlock()
|
|
|
|
if !ok {
|
|
return ErrWCNotPaired
|
|
}
|
|
|
|
if time.Now().After(session.ExpiresAt) {
|
|
return ErrWCSessionExpired
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (b *WalletConnectBackend) IsLocked(name string) bool {
|
|
b.mu.RLock()
|
|
defer b.mu.RUnlock()
|
|
|
|
session, ok := b.sessions[name]
|
|
if !ok {
|
|
return true
|
|
}
|
|
return time.Now().After(session.ExpiresAt)
|
|
}
|
|
|
|
// Sign sends a signing request to the connected wallet
|
|
func (b *WalletConnectBackend) Sign(ctx context.Context, name string, request SignRequest) (*SignResponse, error) {
|
|
b.mu.RLock()
|
|
session, ok := b.sessions[name]
|
|
b.mu.RUnlock()
|
|
|
|
if !ok {
|
|
return nil, ErrWCNotPaired
|
|
}
|
|
|
|
if time.Now().After(session.ExpiresAt) {
|
|
return nil, ErrWCSessionExpired
|
|
}
|
|
|
|
// Display signing request info
|
|
fmt.Printf("\n=== WalletConnect Signing Request ===\n")
|
|
fmt.Printf("Wallet: %s (%s)\n", name, session.PeerName)
|
|
fmt.Printf("Address: %s\n", session.Address)
|
|
fmt.Printf("Type: %s\n", request.Type)
|
|
fmt.Printf("Chain ID: %d\n", request.ChainID)
|
|
if request.Description != "" {
|
|
fmt.Printf("Description: %s\n", request.Description)
|
|
}
|
|
fmt.Printf("Data Hash: 0x%s\n", hex.EncodeToString(request.DataHash[:]))
|
|
fmt.Printf("\nPlease approve the request in your mobile wallet...\n")
|
|
fmt.Printf("=====================================\n\n")
|
|
|
|
// Create JSON-RPC request
|
|
var method string
|
|
var params interface{}
|
|
|
|
switch request.Type {
|
|
case "message", "auth":
|
|
// EIP-191 personal_sign: params = [message, address]
|
|
method = wcMethodPersonalSign
|
|
// Message should be hex-encoded with 0x prefix
|
|
msgHex := "0x" + hex.EncodeToString(request.Data)
|
|
params = []string{msgHex, session.Address}
|
|
|
|
case "typed_data":
|
|
// EIP-712 eth_signTypedData_v4: params = [address, typedData]
|
|
method = wcMethodSignTypedV4
|
|
params = []interface{}{session.Address, string(request.Data)}
|
|
|
|
case "transaction":
|
|
// eth_signTransaction: params = [txObject]
|
|
method = wcMethodSignTx
|
|
params = []json.RawMessage{request.Data}
|
|
|
|
default:
|
|
// Default to personal_sign
|
|
method = wcMethodPersonalSign
|
|
msgHex := "0x" + hex.EncodeToString(request.Data)
|
|
params = []string{msgHex, session.Address}
|
|
}
|
|
|
|
// Send request and wait for response
|
|
sig, err := b.sendRequest(ctx, session, method, params)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &SignResponse{
|
|
Signature: sig,
|
|
Address: session.Address,
|
|
}, nil
|
|
}
|
|
|
|
// Pair initiates a new WalletConnect pairing session
|
|
// Returns the pairing URI that should be displayed as QR code
|
|
func (b *WalletConnectBackend) Pair(ctx context.Context, name string, chainID int) (string, error) {
|
|
// Generate random topic and symmetric key
|
|
topic := make([]byte, 32)
|
|
if _, err := rand.Read(topic); err != nil {
|
|
return "", fmt.Errorf("failed to generate topic: %w", err)
|
|
}
|
|
|
|
symKey := make([]byte, 32)
|
|
if _, err := rand.Read(symKey); err != nil {
|
|
return "", fmt.Errorf("failed to generate symmetric key: %w", err)
|
|
}
|
|
|
|
topicHex := hex.EncodeToString(topic)
|
|
symKeyHex := hex.EncodeToString(symKey)
|
|
|
|
// Create pairing URI
|
|
// Format: wc:{topic}@{version}?relay-protocol=irn&symKey={symKey}
|
|
uri := fmt.Sprintf("%s:%s@%s?relay-protocol=irn&symKey=%s",
|
|
wcProtocolID, topicHex, wcVersion, symKeyHex)
|
|
|
|
// Create session placeholder
|
|
session := &wcSession{
|
|
Topic: topicHex,
|
|
SymKey: symKey,
|
|
ChainID: chainID,
|
|
PairedAt: time.Now(),
|
|
ExpiresAt: time.Now().Add(wcSessionExpiry),
|
|
}
|
|
|
|
b.mu.Lock()
|
|
b.sessions[name] = session
|
|
b.mu.Unlock()
|
|
|
|
return uri, nil
|
|
}
|
|
|
|
// DisplayQR generates and displays a QR code in the terminal
|
|
func (*WalletConnectBackend) DisplayQR(uri string) error {
|
|
// Generate QR code
|
|
qr, err := qrcode.New(uri, qrcode.Medium)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to generate QR code: %w", err)
|
|
}
|
|
|
|
// Print QR code to terminal
|
|
fmt.Println("\n=== Scan with your mobile wallet ===")
|
|
fmt.Println(qr.ToSmallString(false))
|
|
fmt.Println("====================================")
|
|
fmt.Printf("\nURI: %s\n\n", uri)
|
|
|
|
return nil
|
|
}
|
|
|
|
// WaitForPairing waits for a wallet to connect
|
|
func (b *WalletConnectBackend) WaitForPairing(ctx context.Context, name string) (*wcSession, error) {
|
|
b.mu.RLock()
|
|
session, ok := b.sessions[name]
|
|
b.mu.RUnlock()
|
|
|
|
if !ok {
|
|
return nil, ErrWCNotPaired
|
|
}
|
|
|
|
// Connect to relay
|
|
if err := b.connectRelay(ctx, session.Topic); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Wait for session proposal from wallet
|
|
ctx, cancel := context.WithTimeout(ctx, wcPairingTimeout)
|
|
defer cancel()
|
|
|
|
fmt.Println("Waiting for wallet to connect...")
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ErrWCTimeout
|
|
|
|
default:
|
|
// Read message from relay
|
|
_, message, err := b.conn.ReadMessage()
|
|
if err != nil {
|
|
if websocket.IsCloseError(err, websocket.CloseNormalClosure) {
|
|
return nil, ErrWCDisconnected
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Parse session response
|
|
var resp struct {
|
|
Topic string `json:"topic"`
|
|
Type string `json:"type"`
|
|
Payload struct {
|
|
Params struct {
|
|
Accounts []string `json:"accounts"`
|
|
PeerMeta struct {
|
|
Name string `json:"name"`
|
|
Icon string `json:"icon"`
|
|
} `json:"peerMeta"`
|
|
} `json:"params"`
|
|
} `json:"payload"`
|
|
}
|
|
|
|
if err := json.Unmarshal(message, &resp); err != nil {
|
|
continue
|
|
}
|
|
|
|
if resp.Type == "session_proposal" || resp.Type == "session_approval" {
|
|
// Extract address from accounts (format: "eip155:1:0x...")
|
|
if len(resp.Payload.Params.Accounts) > 0 {
|
|
parts := strings.Split(resp.Payload.Params.Accounts[0], ":")
|
|
if len(parts) >= 3 {
|
|
session.Address = parts[2]
|
|
} else {
|
|
session.Address = resp.Payload.Params.Accounts[0]
|
|
}
|
|
}
|
|
session.PeerName = resp.Payload.Params.PeerMeta.Name
|
|
session.PeerIcon = resp.Payload.Params.PeerMeta.Icon
|
|
|
|
// Update session
|
|
b.mu.Lock()
|
|
b.sessions[name] = session
|
|
b.mu.Unlock()
|
|
|
|
// Save session to disk
|
|
if err := b.saveSessions(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
fmt.Printf("\nConnected to %s\n", session.PeerName)
|
|
fmt.Printf("Address: %s\n", session.Address)
|
|
|
|
return session, nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// connectRelay establishes WebSocket connection to WalletConnect relay
|
|
func (b *WalletConnectBackend) connectRelay(ctx context.Context, topic string) error {
|
|
if b.conn != nil {
|
|
return nil // Already connected
|
|
}
|
|
|
|
// Build relay URL with project ID
|
|
relayURL := fmt.Sprintf("%s/?projectId=%s", wcRelayURL, b.projectID)
|
|
|
|
// Set up WebSocket dialer with timeout
|
|
dialer := websocket.Dialer{
|
|
HandshakeTimeout: wcConnectTimeout,
|
|
}
|
|
|
|
// Connect
|
|
conn, resp, err := dialer.DialContext(ctx, relayURL, http.Header{
|
|
"Origin": []string{"https://lux.network"},
|
|
})
|
|
if err != nil {
|
|
if resp != nil {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
_ = resp.Body.Close()
|
|
return fmt.Errorf("relay connection failed: %s: %w", string(body), err)
|
|
}
|
|
return fmt.Errorf("relay connection failed: %w", err)
|
|
}
|
|
|
|
b.conn = conn
|
|
b.done = make(chan struct{})
|
|
|
|
// Subscribe to topic
|
|
subscribeMsg := map[string]interface{}{
|
|
"id": time.Now().UnixNano(),
|
|
"jsonrpc": "2.0",
|
|
"method": "irn_subscribe",
|
|
"params": map[string]string{
|
|
"topic": topic,
|
|
},
|
|
}
|
|
|
|
if err := conn.WriteJSON(subscribeMsg); err != nil {
|
|
_ = conn.Close()
|
|
b.conn = nil
|
|
return fmt.Errorf("failed to subscribe to topic: %w", err)
|
|
}
|
|
|
|
// Start heartbeat
|
|
go b.heartbeat()
|
|
|
|
return nil
|
|
}
|
|
|
|
// heartbeat sends periodic pings to keep connection alive
|
|
func (b *WalletConnectBackend) heartbeat() {
|
|
ticker := time.NewTicker(wcHeartbeatPeriod)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-b.done:
|
|
return
|
|
case <-ticker.C:
|
|
b.mu.RLock()
|
|
conn := b.conn
|
|
b.mu.RUnlock()
|
|
|
|
if conn != nil {
|
|
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// sendRequest sends a JSON-RPC request to the wallet via relay
|
|
func (b *WalletConnectBackend) sendRequest(ctx context.Context, session *wcSession, method string, params interface{}) ([]byte, error) {
|
|
// Connect to relay if not connected
|
|
if err := b.connectRelay(ctx, session.Topic); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Create request
|
|
reqID := time.Now().UnixNano()
|
|
req := wcRequest{
|
|
ID: reqID,
|
|
JSONRPC: "2.0",
|
|
Method: method,
|
|
Params: params,
|
|
}
|
|
|
|
// Encode request
|
|
reqData, err := json.Marshal(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to encode request: %w", err)
|
|
}
|
|
|
|
// Send via relay
|
|
publishMsg := map[string]interface{}{
|
|
"id": time.Now().UnixNano(),
|
|
"jsonrpc": "2.0",
|
|
"method": "irn_publish",
|
|
"params": map[string]interface{}{
|
|
"topic": session.Topic,
|
|
"message": hex.EncodeToString(reqData),
|
|
"ttl": 300, // 5 minutes
|
|
"tag": 1100, // session request tag
|
|
},
|
|
}
|
|
|
|
if err := b.conn.WriteJSON(publishMsg); err != nil {
|
|
return nil, fmt.Errorf("failed to publish request: %w", err)
|
|
}
|
|
|
|
// Wait for response
|
|
ctx, cancel := context.WithTimeout(ctx, wcSigningTimeout)
|
|
defer cancel()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ErrWCTimeout
|
|
|
|
default:
|
|
// Read response
|
|
_, message, err := b.conn.ReadMessage()
|
|
if err != nil {
|
|
if websocket.IsCloseError(err, websocket.CloseNormalClosure) {
|
|
return nil, ErrWCDisconnected
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Parse relay message
|
|
var relayMsg struct {
|
|
ID int64 `json:"id"`
|
|
JSONRPC string `json:"jsonrpc"`
|
|
Method string `json:"method"`
|
|
Params struct {
|
|
Topic string `json:"topic"`
|
|
Message string `json:"message"`
|
|
} `json:"params"`
|
|
}
|
|
|
|
if err := json.Unmarshal(message, &relayMsg); err != nil {
|
|
continue
|
|
}
|
|
|
|
if relayMsg.Method != "irn_subscription" {
|
|
continue
|
|
}
|
|
|
|
// Decode inner message
|
|
msgData, err := hex.DecodeString(relayMsg.Params.Message)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
var resp wcResponse
|
|
if err := json.Unmarshal(msgData, &resp); err != nil {
|
|
continue
|
|
}
|
|
|
|
// Check if this is our response
|
|
if resp.ID != reqID {
|
|
continue
|
|
}
|
|
|
|
// Check for error
|
|
if resp.Error != nil {
|
|
if resp.Error.Code == 4001 {
|
|
return nil, ErrWCUserRejected
|
|
}
|
|
return nil, fmt.Errorf("wallet error: %s (code %d)", resp.Error.Message, resp.Error.Code)
|
|
}
|
|
|
|
// Parse signature result
|
|
var sigHex string
|
|
if err := json.Unmarshal(resp.Result, &sigHex); err != nil {
|
|
return nil, ErrWCInvalidResponse
|
|
}
|
|
|
|
// Decode hex signature
|
|
sigHex = strings.TrimPrefix(sigHex, "0x")
|
|
sig, err := hex.DecodeString(sigHex)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decode signature: %w", err)
|
|
}
|
|
|
|
return sig, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// loadSessions loads saved sessions from disk
|
|
func (b *WalletConnectBackend) loadSessions() error {
|
|
sessionsFile := filepath.Join(b.dataDir, "sessions.json")
|
|
|
|
data, err := os.ReadFile(sessionsFile) //nolint:gosec // G304: Reading from app's data directory
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
var sessions map[string]*wcSession
|
|
if err := json.Unmarshal(data, &sessions); err != nil {
|
|
return err
|
|
}
|
|
|
|
b.mu.Lock()
|
|
b.sessions = sessions
|
|
b.mu.Unlock()
|
|
|
|
// Remove expired sessions
|
|
b.mu.Lock()
|
|
for name, session := range b.sessions {
|
|
if time.Now().After(session.ExpiresAt) {
|
|
for i := range session.SymKey {
|
|
session.SymKey[i] = 0
|
|
}
|
|
delete(b.sessions, name)
|
|
}
|
|
}
|
|
b.mu.Unlock()
|
|
|
|
return nil
|
|
}
|
|
|
|
// saveSessions saves sessions to disk
|
|
func (b *WalletConnectBackend) saveSessions() error {
|
|
b.mu.RLock()
|
|
data, err := json.MarshalIndent(b.sessions, "", " ")
|
|
b.mu.RUnlock()
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
sessionsFile := filepath.Join(b.dataDir, "sessions.json")
|
|
return os.WriteFile(sessionsFile, data, 0o600)
|
|
}
|
|
|
|
// GetSessionChecksum returns a checksum for session verification
|
|
func (b *WalletConnectBackend) GetSessionChecksum(name string) (string, error) {
|
|
b.mu.RLock()
|
|
session, ok := b.sessions[name]
|
|
b.mu.RUnlock()
|
|
|
|
if !ok {
|
|
return "", ErrWCNotPaired
|
|
}
|
|
|
|
h := sha256.Sum256([]byte(session.Topic + session.Address))
|
|
return hex.EncodeToString(h[:8]), nil
|
|
}
|
|
|
|
// SignPersonal signs a message using EIP-191 personal_sign
|
|
func (b *WalletConnectBackend) SignPersonal(ctx context.Context, name string, message []byte) ([]byte, error) {
|
|
request := SignRequest{
|
|
Type: "message",
|
|
Data: message,
|
|
}
|
|
copy(request.DataHash[:], sha256Sum(message))
|
|
|
|
resp, err := b.Sign(ctx, name, request)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return resp.Signature, nil
|
|
}
|
|
|
|
// SignTypedData signs typed data using EIP-712
|
|
func (b *WalletConnectBackend) SignTypedData(ctx context.Context, name string, typedData []byte) ([]byte, error) {
|
|
request := SignRequest{
|
|
Type: "typed_data",
|
|
Data: typedData,
|
|
}
|
|
copy(request.DataHash[:], sha256Sum(typedData))
|
|
|
|
resp, err := b.Sign(ctx, name, request)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return resp.Signature, nil
|
|
}
|
|
|
|
func sha256Sum(data []byte) []byte {
|
|
h := sha256.Sum256(data)
|
|
return h[:]
|
|
}
|
|
|
|
func init() {
|
|
RegisterBackend(NewWalletConnectBackend())
|
|
}
|