mirror of
https://github.com/luxfi/wallet.git
synced 2026-10-30 15:13:19 +00:00
171 lines
5.3 KiB
Go
171 lines
5.3 KiB
Go
// MPC custody adapter — speaks the MPC wire (M-Chain by default).
|
|
//
|
|
// Wallet creation calls MPC `POST /keygen {org_id, wallet_id}` (the verified
|
|
// contract: returns ecdsa/eddsa pubkeys + evm/btc/sol addresses, never a
|
|
// private key). Signing calls `POST /sign` with the org-scoped wallet + payload
|
|
// hash and idempotency key, conforming to the MPC client's Sign contract.
|
|
//
|
|
// The MPC endpoint is per-tenant and injected. The default is M-Chain
|
|
// (api.lux.network/v1/chain/M), where the ceremony runs across the chain's own
|
|
// validators. A tenant running centralized or bring-your-own MPC points at
|
|
// mpc.lux.cloud instead — the exception, asked for rather than assumed. Auth to MPC is a service token (the MPC nodes'
|
|
// internalAuth); user identity/org is carried in the body and re-derived by MPC
|
|
// from its own bearer claims at the consensus layer.
|
|
package custody
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// MPCConfig configures the lux/mpc adapter. Endpoint is the MPC API base
|
|
// (e.g. https://mpc.lux.network); ServiceToken authenticates to the MPC nodes.
|
|
type MPCConfig struct {
|
|
Endpoint string
|
|
ServiceToken string
|
|
HTTPClient *http.Client
|
|
}
|
|
|
|
// mpcCustodian is the HTTP-backed Custodian.
|
|
type mpcCustodian struct {
|
|
endpoint string
|
|
token string
|
|
hc *http.Client
|
|
}
|
|
|
|
// NewMPC builds an MPC-backed Custodian. Returns an error if no endpoint is
|
|
// configured — there is no in-process fallback custody (no plaintext keys, ever).
|
|
func NewMPC(cfg MPCConfig) (Custodian, error) {
|
|
if strings.TrimSpace(cfg.Endpoint) == "" {
|
|
return nil, fmt.Errorf("%w: no MPC endpoint configured", ErrBackendUnavailable)
|
|
}
|
|
hc := cfg.HTTPClient
|
|
if hc == nil {
|
|
hc = &http.Client{Timeout: 90 * time.Second} // keygen can take ~60s
|
|
}
|
|
return &mpcCustodian{
|
|
endpoint: strings.TrimRight(cfg.Endpoint, "/"),
|
|
token: cfg.ServiceToken,
|
|
hc: hc,
|
|
}, nil
|
|
}
|
|
|
|
// keygenResp mirrors lux/mpc cmd/mpcd /keygen response.
|
|
type keygenResp struct {
|
|
WalletID string `json:"wallet_id"`
|
|
ResultType string `json:"result_type"`
|
|
ECDSAPubKey string `json:"ecdsa_pub_key"`
|
|
EDDSAPubKey string `json:"eddsa_pub_key"`
|
|
EVMAddress string `json:"evm_address"`
|
|
BTCAddress string `json:"btc_address"`
|
|
SOLAddress string `json:"sol_address"`
|
|
Error string `json:"error"`
|
|
}
|
|
|
|
func (m *mpcCustodian) CreateWallet(ctx context.Context, org string) (*Wallet, error) {
|
|
var resp keygenResp
|
|
if err := m.post(ctx, "/keygen", map[string]string{"org_id": org}, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.ResultType != "" && resp.ResultType != "success" {
|
|
return nil, fmt.Errorf("%w: %s", ErrKeygenFailed, resp.Error)
|
|
}
|
|
if resp.WalletID == "" {
|
|
return nil, fmt.Errorf("%w: empty wallet id", ErrKeygenFailed)
|
|
}
|
|
addrs := map[string]string{}
|
|
if resp.EVMAddress != "" {
|
|
addrs["evm"] = resp.EVMAddress
|
|
}
|
|
if resp.BTCAddress != "" {
|
|
addrs["btc"] = resp.BTCAddress
|
|
}
|
|
if resp.SOLAddress != "" {
|
|
addrs["sol"] = resp.SOLAddress
|
|
}
|
|
return &Wallet{
|
|
WalletID: resp.WalletID,
|
|
ECDSAPubKey: resp.ECDSAPubKey,
|
|
EDDSAPubKey: resp.EDDSAPubKey,
|
|
Addresses: addrs,
|
|
}, nil
|
|
}
|
|
|
|
// signReq is the org-scoped MPC sign request. Conforms to the MPC client
|
|
// SignRequest contract: org-scoped, payload-hash-bound, idempotent.
|
|
type signReq struct {
|
|
OrgID string `json:"org_id"`
|
|
WalletID string `json:"wallet_id"`
|
|
KeyType string `json:"key_type"`
|
|
ChainID int `json:"chain_id"`
|
|
PayloadHash string `json:"payload_hash"`
|
|
IdempotencyKey string `json:"idempotency_key"`
|
|
}
|
|
|
|
type signResp struct {
|
|
SessionID string `json:"session_id"`
|
|
Signature string `json:"signature"`
|
|
Status string `json:"status"`
|
|
Error string `json:"error"`
|
|
}
|
|
|
|
func (m *mpcCustodian) Sign(ctx context.Context, org string, req SignRequest) (*SignResult, error) {
|
|
if req.IdempotencyKey == "" {
|
|
return nil, ErrIdempotencyKeyRequired
|
|
}
|
|
switch req.Scheme {
|
|
case SchemeSecp256k1, SchemeEd25519, SchemeMLDSA65:
|
|
default:
|
|
return nil, fmt.Errorf("%w: %q", ErrUnsupportedScheme, req.Scheme)
|
|
}
|
|
body := signReq{
|
|
OrgID: org,
|
|
WalletID: req.WalletID,
|
|
KeyType: string(req.Scheme),
|
|
ChainID: req.ChainID,
|
|
PayloadHash: req.PayloadHash,
|
|
IdempotencyKey: req.IdempotencyKey,
|
|
}
|
|
var resp signResp
|
|
if err := m.post(ctx, "/sign", body, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.Error != "" {
|
|
return nil, fmt.Errorf("custody: sign rejected: %s", resp.Error)
|
|
}
|
|
return &SignResult{Signature: resp.Signature, SessionID: resp.SessionID}, nil
|
|
}
|
|
|
|
// post sends a JSON body to an MPC endpoint and decodes the JSON response.
|
|
func (m *mpcCustodian) post(ctx context.Context, path string, body, out interface{}) error {
|
|
buf, err := json.Marshal(body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, m.endpoint+path, bytes.NewReader(buf))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if m.token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+m.token)
|
|
}
|
|
res, err := m.hc.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: %v", ErrBackendUnavailable, err)
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode == http.StatusServiceUnavailable {
|
|
return fmt.Errorf("%w: MPC peers not ready", ErrBackendUnavailable)
|
|
}
|
|
if res.StatusCode/100 != 2 {
|
|
return fmt.Errorf("custody: MPC %s → status %d", path, res.StatusCode)
|
|
}
|
|
return json.NewDecoder(res.Body).Decode(out)
|
|
}
|