Files
zeekayandClaude Opus 5 a3d82a4fdc Go 1.27.1, and the code it can say more simply
Modules declare go 1.27.1 and the build images follow. go fix applied the
modernizers; the boolp test helper had no callers left once new(expr) could
say it. omitzero is not applied, since it changes what the JSON says.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-16 19:50:18 -07:00

310 lines
9.8 KiB
Go

// Package mpc provides an HTTP client for the MPC daemon API.
package mpc
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// Client communicates with the MPC daemon HTTP API.
type Client struct {
BaseURL string
Token string
HTTPClient *http.Client
}
// NewClient creates an MPC client pointing at the given base URL.
// Token is required — unauthenticated MPC access is not permitted.
func NewClient(baseURL, token string) (*Client, error) {
if token == "" {
return nil, fmt.Errorf("mpc: auth token is required")
}
return &Client{
BaseURL: baseURL,
Token: token,
HTTPClient: &http.Client{
Timeout: 120 * time.Second,
},
}, nil
}
// KeygenRequest is the body sent to POST /v1/vaults/{vaultID}/wallets.
type KeygenRequest struct {
Name string `json:"name"`
KeyType string `json:"key_type"`
Protocol string `json:"protocol"`
}
// KeygenResult is the wallet object returned after keygen.
//
// EVMAddress is the canonical 20-byte EVM-runtime account address —
// the format consumed by every EVM-compatible chain (Lux C-Chain,
// downstream EVM L1s, Hanzo EVM, etc.). The derivation hashes the secp256k1
// pubkey with Keccak256 — that's HOW. The value IS "EVM-runtime
// account address" — that's WHAT.
// Wire tags are snake_case to match the mpcd ZAP keygen response
// (luxfi/mpc pkg/api/server.go KeygenResult: wallet_id / ecdsa_pub_key /
// eddsa_pub_key / evm_address / btc_address / sol_address). The ZAP path in
// zap_client.go is the ONLY live decode path for this struct; the REST Client
// is unused. Do NOT reintroduce camelCase — that silently decoded to an empty
// result (the KMS↔MPC wire drift). See mpc.wire_contract_test.go, which fails
// CI if this diverges from the mpcd contract again.
type KeygenResult struct {
ID string `json:"id,omitempty"`
WalletID string `json:"wallet_id"`
VaultID string `json:"vault_id,omitempty"`
Name *string `json:"name,omitempty"`
KeyType string `json:"key_type,omitempty"`
Protocol string `json:"protocol,omitempty"`
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 int `json:"threshold,omitempty"`
Participants []string `json:"participants,omitempty"`
Version int `json:"version,omitempty"`
Status string `json:"status,omitempty"`
}
// SignRequest is the body sent to POST /v1/generate_mpc_sig or through
// the transaction flow. For validator signing we use the bridge sign endpoint.
// SignRequest is the KMS→MPC threshold-sign payload. Field names/tags match
// the mpcd ZAP sign handler (luxfi/mpc pkg/api/zap_kms_server.go
// kmsZapSignRequest{vault_id, wallet_id, payload}); mpcd requires vault_id AND
// wallet_id and reads the message from `payload`. VaultID is the MPC org/vault
// that owns the wallet — the KMS Manager supplies it from MPC_VAULT_ID.
// KeyType is advisory (mpcd resolves the curve from its key-info store); it is
// carried for the REST path and ignored by the ZAP server.
type SignRequest struct {
VaultID string `json:"vault_id"`
WalletID string `json:"wallet_id"`
KeyType string `json:"key_type,omitempty"`
Payload []byte `json:"payload"`
}
// SignResult is the response from a signing operation. For secp256k1/ECDSA,
// mpcd returns EIP-2 low-S R/S, the recovery id V ("0"/"1"), and Signature as
// the canonical 65-byte r‖s‖v (ecrecover-ready). For ed25519/FROST, Signature
// is the 64-byte blob and V is empty.
type SignResult struct {
R string `json:"r,omitempty"`
S string `json:"s,omitempty"`
V string `json:"v,omitempty"`
Signature string `json:"signature,omitempty"`
}
// ReshareRequest is the body sent to POST /v1/wallets/{id}/reshare.
type ReshareRequest struct {
NewThreshold int `json:"new_threshold"`
NewParticipants []string `json:"new_participants"`
}
// ClusterStatus is the response from GET /v1/status.
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"`
}
// RevealResult is what a quorum returns once it has opened a ciphertext.
type RevealResult struct {
Plaintext []byte `json:"plaintext"`
}
// Wallet is the response from GET /v1/wallets/{id}.
//
// EVMAddress is the canonical 20-byte EVM-runtime account address —
// the format consumed by every EVM-compatible chain. See KeygenResult
// for the naming rationale.
type Wallet struct {
ID string `json:"id"`
WalletID string `json:"walletId"`
VaultID string `json:"vaultId"`
Name *string `json:"name"`
KeyType string `json:"keyType"`
Protocol string `json:"protocol"`
ECDSAPubkey *string `json:"ecdsaPubkey"`
EDDSAPubkey *string `json:"eddsaPubkey"`
EVMAddress *string `json:"evmAddress,omitempty"`
BtcAddress *string `json:"btcAddress"`
SolAddress *string `json:"solAddress"`
Threshold int `json:"threshold"`
Participants []string `json:"participants"`
Version int `json:"version"`
Status string `json:"status"`
}
// APIError represents an error response from the MPC API.
type APIError struct {
StatusCode int
Message string
}
func (e *APIError) Error() string {
return fmt.Sprintf("mpc api: %d %s", e.StatusCode, e.Message)
}
// Keygen triggers distributed key generation via the MPC daemon.
// vaultID is the MPC vault that will hold the wallet.
func (c *Client) Keygen(ctx context.Context, vaultID string, req KeygenRequest) (*KeygenResult, error) {
url := fmt.Sprintf("%s/v1/vaults/%s/wallets", c.BaseURL, vaultID)
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("mpc: marshal keygen request: %w", err)
}
resp, err := c.do(ctx, http.MethodPost, url, body)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
return nil, readError(resp)
}
var result KeygenResult
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("mpc: decode keygen response: %w", err)
}
return &result, nil
}
// Sign triggers threshold signing via the MPC daemon.
// This uses the transaction creation flow.
func (c *Client) Sign(ctx context.Context, req SignRequest) (*SignResult, error) {
url := fmt.Sprintf("%s/v1/transactions", c.BaseURL)
body, err := json.Marshal(map[string]any{
"vault_id": req.VaultID,
"wallet_id": req.WalletID,
"key_type": req.KeyType,
"payload": req.Payload,
"type": "sign",
})
if err != nil {
return nil, fmt.Errorf("mpc: marshal sign request: %w", err)
}
resp, err := c.do(ctx, http.MethodPost, url, body)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return nil, readError(resp)
}
var result SignResult
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("mpc: decode sign response: %w", err)
}
return &result, nil
}
// Reshare triggers key resharing to change threshold or participants.
func (c *Client) Reshare(ctx context.Context, walletID string, req ReshareRequest) error {
url := fmt.Sprintf("%s/v1/wallets/%s/reshare", c.BaseURL, walletID)
body, err := json.Marshal(req)
if err != nil {
return fmt.Errorf("mpc: marshal reshare request: %w", err)
}
resp, err := c.do(ctx, http.MethodPost, url, body)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return readError(resp)
}
return nil
}
// GetWallet retrieves wallet metadata from the MPC daemon.
func (c *Client) GetWallet(ctx context.Context, walletID string) (*Wallet, error) {
url := fmt.Sprintf("%s/v1/wallets/%s", c.BaseURL, walletID)
resp, err := c.do(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, readError(resp)
}
var wallet Wallet
if err := json.NewDecoder(resp.Body).Decode(&wallet); err != nil {
return nil, fmt.Errorf("mpc: decode wallet response: %w", err)
}
return &wallet, nil
}
// Status returns the MPC cluster status.
func (c *Client) Status(ctx context.Context) (*ClusterStatus, error) {
url := fmt.Sprintf("%s/v1/status", c.BaseURL)
resp, err := c.do(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, readError(resp)
}
var status ClusterStatus
if err := json.NewDecoder(resp.Body).Decode(&status); err != nil {
return nil, fmt.Errorf("mpc: decode status response: %w", err)
}
return &status, nil
}
func (c *Client) do(ctx context.Context, method, url string, body []byte) (*http.Response, error) {
var reader io.Reader
if body != nil {
reader = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, url, reader)
if err != nil {
return nil, fmt.Errorf("mpc: create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("mpc: request to %s: %w", url, err)
}
return resp, nil
}
func readError(resp *http.Response) error {
body, _ := io.ReadAll(resp.Body)
var errResp struct {
Error string `json:"error"`
}
if json.Unmarshal(body, &errResp) == nil && errResp.Error != "" {
return &APIError{StatusCode: resp.StatusCode, Message: errResp.Error}
}
return &APIError{StatusCode: resp.StatusCode, Message: string(body)}
}