mirror of
https://github.com/luxfi/kms.git
synced 2026-10-30 16:23:36 +00:00
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]>
310 lines
9.8 KiB
Go
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)}
|
|
}
|