Files
mpc/pkg/api/server.go
T
zeekay 8273105926 The signing curve is derived from the network, not stated by the caller
Solana addresses were correct and receive-only: ConsensusKeyInfoStore.Save was
the only RegisterKey caller and hardcoded "secp256k1", and TriggerSign read the
curve from that field, so no product entry point could reach Ed25519 signing.

The hardcode is not fixed, it is UNREPRESENTABLE. transport.KeyInfo.KeyType is
deleted — the record carries one public key per curve and a PublicKeyFor(curve)
— and RegisterKey lost its keyType parameter, so there is no longer a place to
state a curve wrongly. A scalar KeyType was the wrong shape anyway: one keygen
ceremony produces BOTH a secp256k1 and an Ed25519 key, so "what curve is this
wallet" has no single answer.

The request carries the network instead, and ONE table maps it to a curve, with
NO default arm — KeyTypeForNetwork returns ErrUnknownNetwork rather than
guessing. That is the point: the old default returned secp256k1 for anything
unrecognized, which is how a Solana request would have been signed with the
wrong key.

/sign's 32-byte clamp becomes the SCHEME's rule rather than the endpoint's:
secp256k1 signs a digest and requires exactly 32 bytes, Ed25519 signs the
message at any length. /sign no longer accepts key_type at all — two ways to say
the curve is the misuse being removed — and Network is bound into the
idempotency fingerprint, so the same bytes for SOL and ETH conflict instead of
sharing one cached signature from a different key.

⚠ The trap this nearly shipped with: every pre-existing KeyInfo record has EMPTY
public keys, because the old Save wrote "", "". A naive "does this wallet have
this curve" gate would have refused every existing wallet on every curve. The
check is gated on RecordsCurves() — a record naming no curve denies none, and
the missing share stays the thing that fails closed.

Verified end to end, not by mirroring: a Solana request through
ConsensusMPCBackend.TriggerSign with a real 2-of-3 FROST ring produces a
signature crypto/ed25519.Verify accepts, over a 63-byte message so any
reintroduced clamp or pre-hash fails it; ETH/EVM/BTC/LUX still route to
secp256k1 and must RECOVER to the wallet's key. Four mutations tried, all
caught.

Behavioral risk worth naming: an unrecognized chain string now fails its
transaction closed rather than signing with a default. That is correct, and the
real fix is validating chain at POST /v1/transactions.
2026-08-06 15:59:30 -07:00

634 lines
26 KiB
Go

package api
import (
"context"
"crypto/ed25519"
"encoding/base64"
"net/http"
"os"
"strings"
"time"
"github.com/go-chi/chi/v5"
chimw "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/luxfi/mpc/pkg/custody"
"github.com/luxfi/mpc/pkg/db"
"github.com/luxfi/mpc/pkg/txtracker"
"github.com/luxfi/mpc/pkg/types"
"github.com/luxfi/mpc/pkg/wallet"
"github.com/luxfi/mpc/pkg/webauthn"
)
// MPCBackend is the interface the API layer uses to trigger MPC operations.
// All mutation methods require orgID for tenant-scoped key storage.
//
// TriggerSign takes the network the signature is for. It is not optional and
// there is no curve parameter: a wallet holds a secp256k1 key and an Ed25519
// key at once, so the curve is a property of the request, and the backend
// derives it from the network through the one table in pkg/types. Callers name
// what they are signing for; they never name a curve.
type MPCBackend interface {
TriggerKeygen(orgID, walletID string) (*KeygenResult, error)
TriggerSign(orgID, walletID string, network types.NetworkCode, payload []byte) (*SignResult, error)
TriggerReshare(orgID, walletID string, newThreshold int, newParticipants []string) error
ExportKeyShare(orgID, walletID string) ([]byte, error) // Returns serialized key share for backup encryption
GetClusterStatus() *ClusterStatus
}
// KeygenResult is the backend response from a keygen operation.
//
// EVMAddress is the canonical 20-byte EVM-runtime account address —
// the format consumed by every EVM-compatible chain. The derivation
// hashes the secp256k1 pubkey with Keccak256 — that's HOW. The value
// IS "EVM-runtime account address" — that's WHAT.
type KeygenResult struct {
WalletID string `json:"wallet_id"`
ECDSAPubKey string `json:"ecdsa_pub_key"`
EDDSAPubKey string `json:"eddsa_pub_key"`
EVMAddress string `json:"evm_address,omitempty"`
BtcAddress string `json:"btc_address,omitempty"`
SolAddress string `json:"sol_address,omitempty"`
// Threshold is the number of signers required to use this key, and
// Participants is the configured party set it was generated across.
//
// These are reported so a caller can VERIFY the security property it asked
// for instead of assuming it. Keygen takes no per-request threshold — the
// ring's own --threshold governs — so without these fields a client that
// requests 3-of-5 has no way to learn it actually got something else. That
// is not hypothetical: luxfi/kms pkg/keys/manager.go validated req.Threshold
// and then stored `Threshold: result.Threshold`, which was always 0 because
// mpcd never sent the field. Every validator key set on record reads 0-of-0.
// Clients must compare these against what they requested and fail closed.
Threshold int `json:"threshold,omitempty"`
Participants []string `json:"participants,omitempty"`
}
type SignResult struct {
R string `json:"r,omitempty"`
S string `json:"s,omitempty"`
// V is the ECDSA recovery id ("0" or "1") for secp256k1 signatures, empty
// for schemes without recovery (ed25519/FROST). Signature carries the
// canonical ecrecover-ready blob r‖s‖v (65 bytes) when recoverable.
V string `json:"v,omitempty"`
Signature string `json:"signature,omitempty"`
}
type ClusterStatus struct {
NodeID string `json:"node_id"`
Mode string `json:"mode"`
ExpectedPeers int `json:"expected_peers"`
ConnectedPeers int `json:"connected_peers"`
Ready bool `json:"ready"`
Threshold int `json:"threshold"`
Version string `json:"version"`
}
// HSMProvider abstracts hardware security module operations for co-signing.
type HSMProvider interface {
Sign(ctx context.Context, keyID string, message []byte) ([]byte, error)
Verify(ctx context.Context, keyID string, message, signature []byte) (bool, error)
}
type Server struct {
db *db.Database
mpc MPCBackend
hsm HSMProvider // optional: server-side HSM co-signing
tradeApproval *custody.TradeApprovalService
txTracker *txtracker.Tracker
jwtSecret []byte
oidcIssuers []string
webauthnRPID string
webauthnOrigins map[string]bool
// livenessVerifier validates the liveness attestation attached to
// /v1/mpc/biometric/enroll. Resolved at boot from the configured
// provider id: a white-label-registered Verifier if present, else the
// OSS-default Ed25519 envelope verifier built from the configured
// public key. Nil disables the endpoint (fails closed).
livenessVerifier webauthn.Verifier
livenessProviderID string
// livenessBindingMode controls the R3-8 envelope→enrollment binding.
// BindingStrict (default) rejects envelopes that don't carry
// credentialHash/challengeId. BindingLax logs a warning instead —
// intended only for the liveness provider extended-envelope rollout window.
// Configured via MPC_LIVENESS_BINDING=strict|lax (default strict).
livenessBindingMode webauthn.BindingMode
router chi.Router
replayGuard *replayGuard
// approval is the executive-approval registry consumed by /v1/approval/*.
// Optional — when nil, those endpoints return 503. Wired via SetApproval.
approval *ApprovalRegistry
// tieredWallets is the tiered-wallet registry consumed by /v1/wallets/*.
// Lazily initialized when nil; wired via SetTieredWalletRegistry.
tieredWallets wallet.Registry
// Events is the broadcast channel for server-side events (intent status,
// signing progress, wallet events). Handlers publish here; WebSocket
// clients and ZAP subscribers receive.
Events *EventBus
}
// maxBodySize returns middleware that limits request body size to prevent OOM
// from oversized payloads. Handlers that read beyond the limit get an error
// from http.MaxBytesReader.
func maxBodySize(maxBytes int64) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
next.ServeHTTP(w, r)
})
}
}
func NewServer(database *db.Database, mpcBackend MPCBackend, jwtSecret string, oidcIssuers ...string) *Server {
// Default allowed issuers if none provided
if len(oidcIssuers) == 0 {
oidcIssuers = []string{
"https://hanzo.id",
"https://lux.id",
}
}
// WebAuthn RP ID — configurable via env, default lux.network
rpID := os.Getenv("MPC_WEBAUTHN_RP_ID")
if rpID == "" {
rpID = "lux.network"
}
// WebAuthn allowed origins — configurable via env (comma-separated)
webauthnOrigins := map[string]bool{}
if raw := os.Getenv("MPC_WEBAUTHN_ORIGINS"); raw != "" {
for _, o := range strings.Split(raw, ",") {
o = strings.TrimSpace(o)
if o != "" {
webauthnOrigins[o] = true
}
}
}
// Default WebAuthn origins — tenants add their own via MPC_WEBAUTHN_ORIGINS env.
for _, o := range []string{
"https://lux.network",
"https://mpc.lux.network",
} {
webauthnOrigins[o] = true
}
// Transaction lifecycle tracker (RPC clients added via SetTxTrackerRPC)
tracker := txtracker.New(txtracker.Config{
Database: database,
})
// Liveness attestation verifier. Resolve the active Verifier from the
// configured provider id:
//
// MPC_LIVENESS_PROVIDER_ID — provider id; also the registry key a
// white-label deployment registers under
// MPC_LIVENESS_PUBKEY_ED25519 — base64-standard Ed25519 public key for
// the OSS-default envelope verifier
//
// A deployment that registers its own webauthn.Verifier under the
// provider id (e.g. a regulated broker wiring its own PAD-2 vendor) wins;
// otherwise, if a public key is configured, the OSS-default Ed25519
// envelope verifier is used. With neither, livenessVerifier stays nil
// and /v1/mpc/biometric/enroll refuses every request — an unconfigured
// verifier MUST NOT fall back to trusting body-supplied scores.
livenessProviderID := os.Getenv("MPC_LIVENESS_PROVIDER_ID")
var livenessVerifier webauthn.Verifier
if v, ok := webauthn.LookupVerifier(livenessProviderID); ok {
livenessVerifier = v
} else if raw := os.Getenv("MPC_LIVENESS_PUBKEY_ED25519"); raw != "" {
if dec, err := base64.StdEncoding.DecodeString(raw); err == nil && len(dec) == ed25519.PublicKeySize {
livenessVerifier = webauthn.Ed25519EnvelopeVerifier{
PubKey: ed25519.PublicKey(dec),
ProviderID: livenessProviderID,
}
}
}
// R3-8 binding mode. Default STRICT; ops may toggle to LAX during a
// vendor's extended-envelope rollout. LAX emits a warn log on every
// permissive accept so rollout progress is observable.
bindingMode := webauthn.BindingStrict
if strings.ToLower(strings.TrimSpace(os.Getenv("MPC_LIVENESS_BINDING"))) == "lax" {
bindingMode = webauthn.BindingLax
}
s := &Server{
db: database,
mpc: mpcBackend,
txTracker: tracker,
jwtSecret: []byte(jwtSecret),
oidcIssuers: oidcIssuers,
webauthnRPID: rpID,
webauthnOrigins: webauthnOrigins,
livenessVerifier: livenessVerifier,
livenessProviderID: livenessProviderID,
livenessBindingMode: bindingMode,
replayGuard: newReplayGuard(),
Events: NewEventBus(),
}
// CORS origins — Lux infrastructure only. Tenants add via MPC_CORS_ORIGINS env.
corsOrigins := []string{
"https://cloud.lux.network",
"https://mpc.lux.network",
"https://bridge.lux.network",
"http://localhost:3000",
}
if raw := os.Getenv("MPC_CORS_ORIGINS"); raw != "" {
for _, o := range strings.Split(raw, ",") {
o = strings.TrimSpace(o)
if o != "" {
corsOrigins = append(corsOrigins, o)
}
}
}
r := chi.NewRouter()
r.Use(chimw.RequestID)
r.Use(chimw.RealIP)
r.Use(chimw.Recoverer)
r.Use(maxBodySize(1 << 20)) // 1 MB
r.Use(chimw.Timeout(120 * time.Second))
r.Use(cors.Handler(cors.Options{
AllowedOrigins: corsOrigins,
AllowedMethods: []string{"GET", "POST", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-API-Key"},
ExposedHeaders: []string{"Link"},
AllowCredentials: true,
MaxAge: 300,
}))
r.Use(RateLimitMiddleware(100))
// Landing page
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(landingHTML))
})
// Health check (public, outside /v1, for K8s probes)
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
// Public routes
r.Route("/v1", func(r chi.Router) {
// Auth (no middleware)
r.Post("/auth/register", s.handleRegister)
r.Post("/auth/login", s.handleLogin)
r.Post("/auth/refresh", s.handleRefresh)
r.Post("/auth/oidc", s.handleOIDCExchange)
// WebSocket — auth via query param ?token=<jwt> (headers not sent on WS upgrade)
r.Get("/ws", s.handleWebSocket)
// Public payment page
r.Get("/pay/{token}", s.handlePublicPay)
// Bridge signing endpoints (API key or JWT auth; API key needs "sign" permission)
// Tighter rate limit for signing: 20 RPM per IP (separate from global 100 RPM).
r.Group(func(r chi.Router) {
r.Use(s.authMiddleware)
r.Use(requirePermission("sign"))
r.Use(RateLimitMiddleware(20))
r.Post("/generate_mpc_sig", s.handleBridgeSign)
r.Post("/complete", s.handleBridgeComplete)
})
// Authenticated routes
r.Group(func(r chi.Router) {
r.Use(s.authMiddleware)
r.Use(s.auditMiddleware)
// MFA (any authenticated user)
r.Post("/auth/mfa/setup", s.handleMFASetup)
r.Post("/auth/mfa/verify", s.handleMFAVerify)
// Users & Teams — owner/admin only
r.Group(func(r chi.Router) {
r.Use(requireRole("owner", "admin"))
r.Get("/users", s.handleListUsers)
r.Post("/users", s.handleInviteUser)
r.Patch("/users/{id}", s.handleUpdateUser)
r.Delete("/users/{id}", s.handleDeleteUser)
})
// API Keys — owner/admin only
r.Group(func(r chi.Router) {
r.Use(requireRole("owner", "admin"))
r.Get("/api-keys", s.handleListAPIKeys)
r.Post("/api-keys", s.handleCreateAPIKey)
r.Delete("/api-keys/{id}", s.handleDeleteAPIKey)
})
// Vaults — all authenticated; mutations require admin+
r.Get("/vaults", s.handleListVaults)
r.Get("/vaults/{id}", s.handleGetVault)
r.Group(func(r chi.Router) {
r.Use(requireRole("owner", "admin"))
r.Post("/vaults", s.handleCreateVault)
r.Patch("/vaults/{id}", s.handleUpdateVault)
r.Delete("/vaults/{id}", s.handleDeleteVault)
})
// Wallets — keygen/reshare require admin+; sign requires signer+
r.Get("/vaults/{id}/wallets", s.handleListWallets)
r.Get("/wallets/{id}", s.handleGetWallet)
r.Get("/wallets/{id}/addresses", s.handleGetWalletAddresses)
r.Get("/wallets/{id}/history", s.handleWalletHistory)
r.Group(func(r chi.Router) {
r.Use(requireRole("owner", "admin"))
r.Post("/vaults/{id}/wallets", s.handleCreateWallet)
r.Post("/wallets/{id}/reshare", s.handleReshareWallet)
})
// Transactions (legacy create/approve/reject plumbing kept as
// it is the substrate for the unified /v1/mpc/operations view).
// The read surface for operations is /v1/mpc/operations below;
// direct POST /v1/transactions retained as internal entry point
// for intents/payments which wire their own flows.
r.Group(func(r chi.Router) {
r.Use(requireRoleOrAPIPermission([]string{"owner", "admin", "signer"}, "mpc:transactions:create"))
r.Post("/transactions", s.handleCreateTransaction)
})
// Whitelist — owner/admin only
r.Group(func(r chi.Router) {
r.Use(requireRole("owner", "admin"))
r.Get("/whitelist", s.handleListWhitelist)
r.Post("/whitelist", s.handleAddWhitelist)
r.Delete("/whitelist/{id}", s.handleDeleteWhitelist)
})
// Webhooks — owner/admin only
r.Group(func(r chi.Router) {
r.Use(requireRole("owner", "admin"))
r.Get("/webhooks", s.handleListWebhooks)
r.Post("/webhooks", s.handleCreateWebhook)
r.Patch("/webhooks/{id}", s.handleUpdateWebhook)
r.Delete("/webhooks/{id}", s.handleDeleteWebhook)
r.Post("/webhooks/{id}/test", s.handleTestWebhook)
})
// Subscriptions — signer+ to create/pay; viewers can read
r.Get("/subscriptions", s.handleListSubscriptions)
r.Get("/subscriptions/{id}", s.handleGetSubscription)
r.Group(func(r chi.Router) {
r.Use(requireRoleOrAPIPermission([]string{"owner", "admin", "signer"}, "mpc:subscriptions:write"))
r.Post("/subscriptions", s.handleCreateSubscription)
r.Patch("/subscriptions/{id}", s.handleUpdateSubscription)
r.Delete("/subscriptions/{id}", s.handleDeleteSubscription)
r.Post("/subscriptions/{id}/pay-now", s.handlePayNow)
})
// Payment Requests — signer+ to create/pay
r.Get("/payment-requests", s.handleListPaymentRequests)
r.Get("/payment-requests/{id}", s.handleGetPaymentRequest)
r.Group(func(r chi.Router) {
r.Use(requireRoleOrAPIPermission([]string{"owner", "admin", "signer"}, "mpc:payment-requests:write"))
r.Post("/payment-requests", s.handleCreatePaymentRequest)
r.Post("/payment-requests/{id}/pay", s.handlePayPaymentRequest)
})
// Smart Wallets — admin+ to deploy; signer+ to propose/execute
r.Get("/wallets/{id}/smart-wallets", s.handleListSmartWallets)
r.Get("/smart-wallets/{id}", s.handleGetSmartWallet)
r.Group(func(r chi.Router) {
r.Use(requireRole("owner", "admin"))
r.Post("/wallets/{id}/smart-wallet", s.handleDeploySmartWallet)
})
r.Group(func(r chi.Router) {
r.Use(requireRoleOrAPIPermission([]string{"owner", "admin", "signer"}, "mpc:smart-wallets:write"))
r.Post("/smart-wallets/{id}/propose", s.handleProposeSafeTx)
r.Post("/smart-wallets/{id}/execute", s.handleExecuteSafeTx)
r.Post("/smart-wallets/{id}/user-operation", s.handleUserOperation)
})
// Bridge admin — owner/admin only
r.Group(func(r chi.Router) {
r.Use(requireRole("owner", "admin"))
r.Get("/bridge/config", s.handleGetBridgeConfig)
r.Patch("/bridge/config", s.handleUpdateBridgeConfig)
r.Get("/bridge/networks", s.handleListBridgeNetworks)
})
// WebAuthn/Biometric — any authenticated user can register devices and approve with biometrics
r.Post("/webauthn/register/begin", s.handleRegisterWebAuthnBegin)
r.Post("/webauthn/register/complete", s.handleRegisterWebAuthnComplete)
r.Post("/webauthn/verify", s.handleVerifyWebAuthn) // Biometric approval of transactions
r.Get("/webauthn/credentials", s.handleListWebAuthnCredentials)
r.Delete("/webauthn/credentials/{id}", s.handleDeleteWebAuthnCredential)
// Trade Approval — push notification + biometric signing flow
// Submit requires signer+ (ATS service account); approve/reject require the trade owner
r.Get("/trade/pending", s.handlePendingTrades)
r.Group(func(r chi.Router) {
r.Use(requireRoleOrAPIPermission([]string{"owner", "admin", "signer"}, "mpc:trade:write"))
r.Post("/trade/submit", s.handleTradeSubmit)
r.Post("/trade/approve", s.handleTradeApprove)
r.Post("/trade/reject", s.handleTradeReject)
})
// Intents & Settlements — signer+ can create; viewers can read
r.Get("/intents", s.handleListIntents)
r.Get("/intents/{id}", s.handleGetIntent)
r.Get("/settlements", s.handleListSettlements)
r.Get("/settlements/{id}", s.handleGetSettlement)
r.Group(func(r chi.Router) {
r.Use(requireRoleOrAPIPermission([]string{"owner", "admin", "signer"}, "mpc:sign"))
r.Use(RateLimitMiddleware(20)) // signing rate limit: 20 RPM per IP
r.Post("/intents", s.handleCreateIntent)
r.Post("/intents/{id}/sign", s.handleSignIntent)
r.Post("/intents/{id}/co-sign", s.handleCoSignIntent)
})
// Wallet Backup — admin+ to create; signer+ to recover
r.Group(func(r chi.Router) {
r.Use(requireRole("owner", "admin"))
r.Post("/wallets/{id}/backup", s.handleCreateWalletBackup)
r.Get("/wallets/{id}/backup", s.handleGetWalletBackup)
})
// Legacy audit route removed — moved to /v1/mpc/audit.
// Validator key management (KMS) — owner/admin only
r.Group(func(r chi.Router) {
r.Use(requireRole("owner", "admin"))
s.registerKMSRoutes(r)
})
// Status — any authenticated (including API keys)
r.Get("/status", s.handleStatus)
r.Get("/info", s.handleInfo)
// ── /v1/mpc/* — MPC spec surface
// Canonical routes defined in the MPC OpenAPI spec.
// No alias paths, no backwards compatibility.
r.Route("/mpc", func(r chi.Router) {
// Wallets
r.Get("/wallets", s.handleMpcListWallets)
r.Post("/wallets", s.handleMpcCreateWallet)
r.Get("/wallets/balances", s.handleMpcWalletBalances)
r.Post("/wallets/sweep", s.handleMpcSweepWallet)
r.Get("/wallets/{id}", s.handleMpcGetWallet)
r.Patch("/wallets/{id}/default", s.handleMpcSetDefaultWallet)
// Default wallet
r.Get("/wallet", s.handleMpcGetDefaultWallet)
r.Post("/wallet", s.handleMpcCreateDefaultWallet)
// Balances + crypto
r.Get("/balances/{address}", s.handleMpcBalancesByAddress)
r.Get("/crypto/wallet/{asset}", s.handleMpcCryptoWallet)
// Signing — R2-3: "api" role must additionally carry the
// concrete permission; a bare API key with permissions=[] is
// no longer sufficient.
r.With(requireRoleOrAPIPermission([]string{"owner", "admin", "signer"}, "mpc:sign"), RateLimitMiddleware(20)).
Post("/sign", s.handleMpcSignDefault)
r.With(requireRoleOrAPIPermission([]string{"owner", "admin", "signer"}, "mpc:settlement:sign"), RateLimitMiddleware(20)).
Post("/settlement/sign", s.handleMpcSignSettlement)
// WebAuthn (spec-named aliases to the existing handlers)
r.Post("/webauthn/challenge", s.handleRegisterWebAuthnBegin)
r.Post("/webauthn/verify", s.handleVerifyWebAuthn)
// Biometric
r.Post("/biometric/enroll", s.handleBiometricEnroll)
r.Get("/biometric/status", s.handleBiometricStatus)
// Sessions (wallet-scoped)
r.Route("/wallets/{id}/sessions", func(r chi.Router) {
r.Get("/", s.handleListWalletSessions)
r.Post("/", s.handleCreateWalletSession)
r.Get("/{sessionId}", s.handleGetWalletSession)
r.Delete("/{sessionId}", s.handleRevokeWalletSession)
})
// Operations (unified view over Transaction)
r.Get("/operations", s.handleListOperations)
r.Get("/operations/{operationId}", s.handleGetOperation)
r.With(requireRoleOrAPIPermission([]string{"owner", "admin", "signer"}, "mpc:operations:approve")).
Post("/operations/{operationId}/approve", s.handleApproveOperation)
r.With(requireRoleOrAPIPermission([]string{"owner", "admin", "signer"}, "mpc:operations:approve")).
Post("/operations/{operationId}/reject", s.handleRejectOperation)
// Policies — owner/admin
r.Group(func(r chi.Router) {
r.Use(requireRole("owner", "admin"))
r.Get("/policies", s.handleListPolicies)
r.Post("/policies", s.handleCreatePolicy)
r.Get("/policies/{policyId}", s.handleGetPolicy)
r.Patch("/policies/{policyId}", s.handleUpdatePolicy)
r.Delete("/policies/{policyId}", s.handleDeletePolicy)
})
// Audit — owner/admin
r.With(requireRole("owner", "admin")).Get("/audit", s.handleMpcListAudit)
// Treasury — 3-of-5 org governance surface.
// wallet CRUD requires owner/admin role OR API key with
// mpc.treasury.admin permission. Sign requires a treasury
// signer (principal KeyRef must appear in the signer set).
r.Route("/treasury", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(requireRoleOrAPIPermission(
[]string{"owner", "admin"}, treasuryAdminPermission))
r.Post("/wallets", s.handleCreateTreasuryWallet)
r.Get("/wallets", s.handleListTreasuryWallets)
r.Get("/wallets/{id}", s.handleGetTreasuryWallet)
r.Post("/wallets/{id}/rotate-signer", s.handleRotateTreasurySigner)
})
r.With(requireRoleOrAPIPermission(
[]string{"owner", "admin", "signer"}, treasurySignPermission)).
Post("/sign", s.handleTreasurySign)
})
})
})
})
// KMS routes are registered INSIDE the authenticated route group above.
// See registerKMSRoutes — it applies requireRole("owner", "admin").
// Start background intent expiry reaper
s.StartIntentReaper(context.Background(), 5*time.Minute)
// Start background trade expiry reaper (60s interval — trades expire in 5 min)
s.StartTradeReaper(context.Background(), 60*time.Second)
s.router = r
return s
}
// SetHSM configures the server-side HSM provider for intent co-signing.
// When set, the co-sign endpoint signs with the HSM directly instead of
// accepting client-submitted signatures (prevents forged co-signatures).
func (s *Server) SetHSM(hsm HSMProvider) {
s.hsm = hsm
}
// SetTradeApproval configures the trade approval service for push notification
// and biometric approval flow. When set, the /v1/trade endpoints are active.
func (s *Server) SetTradeApproval(notifier custody.PushNotifier) {
s.tradeApproval = custody.NewTradeApprovalService(s.db.ORM, notifier)
}
// Handler returns the underlying http.Handler for mounting on external servers (e.g. Hanzo Base).
func (s *Server) Handler() http.Handler {
return s.router
}
const landingHTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MPC — Threshold Signing Service</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0a0a0a;color:#e5e5e5;display:flex;align-items:center;justify-content:center;min-height:100vh}
.c{max-width:720px;padding:3rem 2rem;text-align:center}
h1{font-size:2.5rem;font-weight:700;margin-bottom:.5rem;background:linear-gradient(135deg,#7c3aed,#2563eb);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.tag{color:#a1a1aa;font-size:1.1rem;margin-bottom:2.5rem}
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1rem;margin:2rem 0;text-align:left}
.card{background:#18181b;border:1px solid #27272a;border-radius:12px;padding:1.25rem}
.card h3{font-size:.95rem;margin-bottom:.5rem;color:#a78bfa}
.card p{font-size:.85rem;color:#a1a1aa;line-height:1.5}
.chains{display:flex;flex-wrap:wrap;gap:.5rem;justify-content:center;margin:1.5rem 0}
.chip{background:#1e1b4b;border:1px solid #312e81;color:#c4b5fd;padding:.35rem .75rem;border-radius:999px;font-size:.8rem}
.links{margin-top:2rem;display:flex;gap:1rem;justify-content:center;flex-wrap:wrap}
.links a{color:#818cf8;text-decoration:none;font-size:.9rem;padding:.5rem 1rem;border:1px solid #312e81;border-radius:8px;transition:all .2s}
.links a:hover{background:#1e1b4b;border-color:#4f46e5}
.status{margin-top:2rem;font-size:.8rem;color:#52525b}
</style>
</head>
<body>
<div class="c">
<h1>MPC</h1>
<p class="tag">Threshold Signing Service &bull; 3-of-5 Consensus</p>
<div class="cards">
<div class="card"><h3>CGGMP21</h3><p>5-round threshold ECDSA (secp256k1) for Bitcoin, Ethereum, Lux, XRPL, and all EVM chains.</p></div>
<div class="card"><h3>FROST</h3><p>2-round threshold EdDSA (Ed25519) for Solana, TON. BIP-340 Schnorr for Bitcoin Taproot.</p></div>
<div class="card"><h3>Bridge</h3><p>Cross-chain asset bridge with MPC-signed transactions. Multi-network, policy-driven approvals.</p></div>
</div>
<div class="chains">
<span class="chip">Bitcoin</span><span class="chip">Ethereum</span><span class="chip">Lux</span>
<span class="chip">Solana</span><span class="chip">XRPL</span><span class="chip">TON</span>
<span class="chip">Polygon</span><span class="chip">Arbitrum</span><span class="chip">Base</span><span class="chip">BNB</span>
</div>
<div class="links">
<a href="/healthz">API Status</a>
<a href="/health">Cluster Health</a>
<a href="https://bridge.lux.network">Bridge Dashboard</a>
<a href="/v1/bridge/networks">Networks</a>
</div>
<p class="status">v0.3.3 &bull; Post-Quantum TLS 1.3 &bull; ZapDB Encrypted Storage</p>
</div>
</body>
</html>`