Files
bridge/internal/mchain/client.go
T
zeekay e661decabb chain routes: /v1/bc -> /v1/chain
luxd serves chain routes under /v1/chain, and the node, cli and sdk releases
already ship it. The deployed gateways still answer the old prefix, so remote
reads break until they are redeployed; the estate moves together rather than
sitting half converted.

Lines that record an observation - a probe, a status code, a past release -
keep the prefix they were written against, as do URLs on hosts that are not
ours. Avalanche endpoints move to /ext/bc, which is the path that host serves.
2026-09-01 08:20:22 -07:00

913 lines
35 KiB
Go

// Package mchain is a Go client for the Lux MPC keygen REST API (m-chain).
//
// This is the Go port of `app/server/src/domain/mpc-wallet.ts` —
// specifically `createMPCWalletForDeposit`, which triggers a fresh MPC
// keygen on the cluster and returns a chain-appropriate deposit
// address (ETH for EVM, BTC for Bitcoin, SOL for Solana, etc.).
//
// Naming: the boss calls it "m-chain" in the broader sense ("proxy to
// b-chain and m-chain"). The MPC keygen API is HTTP (port 9800 internal
// k8s service `http://mpc-node-0.mpc-node-headless.lux-mpc.svc:9800`),
// not a JSON-RPC chain endpoint — those live at `/v1/chain/T/rpc` and
// are handled by internal/bchain's ThresholdRPCURL path. Both belong
// to the m-chain "layer" by the boss's vocabulary.
//
// Trust model: the keygen URL is treated as a trusted internal service
// — secrets do leave the MPC cluster via the keygen response in the
// form of public addresses + public keys. Private key material never
// crosses this boundary (that's the whole point of the MPC threshold:
// no node ever holds a complete key).
//
// Concurrency: a *Client is safe for use by multiple goroutines.
package mchain
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
"github.com/luxfi/bridge/internal/xrpl"
)
// =============================================================================
// Network → address-type registry
// =============================================================================
// AddressType is the signature scheme / address-encoding scheme an MPC
// keygen result must surface for a given source network.
type AddressType string
const (
AddressTypeETH AddressType = "eth"
AddressTypeBTC AddressType = "btc"
AddressTypeSOL AddressType = "sol"
AddressTypeTON AddressType = "ton"
AddressTypeXRP AddressType = "xrp"
AddressTypeDOT AddressType = "dot"
// Cardano. Same Ed25519 key as Solana and a different address: bech32
// addr1… over blake2b-224 (CIP-19) rather than the base58 public key.
// Mapping it to SOL, as this table did, issues a Solana-shaped string
// to send ADA to.
AddressTypeADA AddressType = "ada"
)
// Curve names the threshold-signature scheme the MPC cluster keygens /
// signs under. The lux-mpc dashboard (mpcd v2026-05+) accepts a
// per-request curve switch; default is ECDSA/secp256k1 for backwards
// compatibility with the original ETH/BTC pipeline. Solana / Cardano
// / Polkadot need Ed25519 (FROST). Each address type maps to exactly
// one curve — CurveFor() is the source of truth.
type Curve string
const (
// CurveSecp256k1 = ECDSA on secp256k1. ETH + BTC.
CurveSecp256k1 Curve = "secp256k1"
// CurveEd25519 = EdDSA on Ed25519 via FROST. SOL + TON + DOT + Cardano.
CurveEd25519 Curve = "ed25519"
)
// CurveFor returns the canonical curve for a given address family.
// Used to populate the `curve` field on the keygen + sign request
// bodies — the dashboard hands back the curve-appropriate pubkey slot
// (eddsa_pub_key for Ed25519, ecdsa_pub_key otherwise).
//
// XRP is secp256k1 with rAddress derivation; the bridge tracks it as
// AddressTypeXRP but the underlying key is the same as ETH today, so
// it lands on secp256k1. Cardano shares the SOL slot per the legacy
// TS mapping — its underlying key is Ed25519.
func CurveFor(t AddressType) Curve {
switch t {
case AddressTypeSOL, AddressTypeTON, AddressTypeDOT:
return CurveEd25519
default:
return CurveSecp256k1
}
}
// networkAddressType mirrors NETWORK_ADDRESS_TYPE in mpc-wallet.ts.
// Unknown networks default to AddressTypeETH (matches TS behavior).
var networkAddressType = map[string]AddressType{
// EVM chains use eth address
"ETHEREUM_MAINNET": AddressTypeETH,
"ETHEREUM_SEPOLIA": AddressTypeETH,
"ETHEREUM_GOERLI": AddressTypeETH,
"BASE_MAINNET": AddressTypeETH,
"BASE_SEPOLIA": AddressTypeETH,
"HOLESKY_TESTNET": AddressTypeETH,
"LUX_MAINNET": AddressTypeETH,
"LUX_TESTNET": AddressTypeETH,
"LUX_DEVNET": AddressTypeETH,
"ZOO_MAINNET": AddressTypeETH,
"ZOO_TESTNET": AddressTypeETH,
"ZOO_DEVNET": AddressTypeETH,
"HANZO_MAINNET": AddressTypeETH,
"PARS_MAINNET": AddressTypeETH,
"OSAGE_MAINNET": AddressTypeETH,
"BSC_MAINNET": AddressTypeETH,
"BSC_TESTNET": AddressTypeETH,
"POLYGON_MAINNET": AddressTypeETH,
"ARBITRUM_MAINNET": AddressTypeETH,
"OPTIMISM_MAINNET": AddressTypeETH,
"AVAX_MAINNET": AddressTypeETH,
"FANTOM_MAINNET": AddressTypeETH,
"CELO_MAINNET": AddressTypeETH,
"GNOSIS_MAINNET": AddressTypeETH,
"AURORA_MAINNET": AddressTypeETH,
"ZORA_MAINNET": AddressTypeETH,
"BLAST_MAINNET": AddressTypeETH,
"LINEA_MAINNET": AddressTypeETH,
// Bitcoin
"BITCOIN_MAINNET": AddressTypeBTC,
"BITCOIN_TESTNET": AddressTypeBTC,
// Solana
"SOLANA_MAINNET": AddressTypeSOL,
"SOLANA_DEVNET": AddressTypeSOL,
"SOLANA_TESTNET": AddressTypeSOL,
// TON
"TON_MAINNET": AddressTypeTON,
"TON_TESTNET": AddressTypeTON,
// XRP
"XRP_MAINNET": AddressTypeXRP,
"XRP_TESTNET": AddressTypeXRP,
// Polkadot / Substrate
"POLKADOT_MAINNET": AddressTypeDOT,
"POLKADOT_TESTNET": AddressTypeDOT,
"KUSAMA_MAINNET": AddressTypeDOT,
// Cardano — placeholder (Ed25519); use the sol address slot until a
// proper Cardano encoder lands. Matches TS mpc-wallet.ts behaviour.
"CARDANO_MAINNET": AddressTypeADA,
}
// AddressTypeFor returns the configured address type for a network, and
// whether the network is known.
//
// The default used to be AddressTypeETH, on the reasoning that "every
// BridgeVM-supported chain is at minimum an EVM". That has not been true
// since Bitcoin: an unlisted non-EVM network silently became an EVM one, and
// an 0x address was handed out for a chain that has never seen one. A network
// this table does not name is a network whose address format is unknown, and
// unknown is not eth.
//
// Callers that need a value for a known-EVM network should look it up; callers
// that get ok=false should refuse rather than guess.
func AddressTypeFor(networkInternalName string) (AddressType, bool) {
t, ok := networkAddressType[networkInternalName]
return t, ok
}
// =============================================================================
// Types
// =============================================================================
// keygenResult mirrors the MPCKeygenResult wire shape from
// mpc-wallet.ts. Internal to the package; callers consume *Wallet.
//
// EVMAddress is the alternate name mpcd uses on the wire ("evm_address"
// in /root/luxify/mpc/cmd/mpcd/main.go around line 665). We accept
// either spelling so the bridge keeps working through the renaming
// transition.
//
// BTCAddress is mpcd's legacy P2PKH "1..." address. For bridge-side
// release flows we don't use it directly — we re-derive a P2WPKH
// bech32 address from ECDSAPubKey via deriveBTCBech32Address. The
// P2WPKH form is what btcsuite txscript/wire actually expects on the
// release side; mpcd's legacy P2PKH is preserved on the wire only for
// SDK back-compat.
type keygenResult struct {
WalletID string `json:"wallet_id"`
ECDSAPubKey string `json:"ecdsa_pub_key"`
EDDSAPubKey string `json:"eddsa_pub_key"`
ETHAddress string `json:"eth_address"`
EVMAddress string `json:"evm_address"`
BTCAddress string `json:"btc_address"`
SOLAddress string `json:"sol_address"`
ResultType string `json:"result_type"`
Error string `json:"error"`
}
// Wallet is the public result of a keygen for one bridge deposit OR for
// a long-lived per-network release wallet. Caller stores Name + Address;
// Name doubles as the MPC wallet identifier that the deposit-watcher and
// signing-session code use to recover the underlying key shares.
//
// JSON tags are present because FileReleaseStore serializes Wallet to
// disk so per-network release wallets survive bridge restarts.
type Wallet struct {
// Name is the MPC wallet identifier (e.g. "bridge-ethereum_sepolia-1718000000").
Name string `json:"name"`
// Address is the source-chain receive address derived from the
// keygen output, picked according to AddressTypeFor(network). For
// BTC, this is the bech32 P2WPKH derived locally from ECDSAPubKey
// (mpcd's legacy P2PKH form is intentionally not used — P2WPKH is
// what the bridge release flow consumes).
Address string `json:"address"`
// AddressType is the family of Address. Useful for downstream code
// that needs to render or validate the address.
AddressType AddressType `json:"address_type"`
// ECDSAPubKey is the 33-byte compressed secp256k1 public key the
// MPC quorum produced. Required by:
// - BTC release: witness stack on Finalize (P2WPKH spends include
// the pubkey alongside the DER signature).
// - DOT release: assembler derives the AccountId32 + picks the
// ECDSA recovery byte at Finalize time.
// - XRP release: tx wire payload embeds the pubkey + verifiers
// reconstruct the address from it.
// Empty when the keygen result didn't carry an ECDSA pubkey
// (Ed25519-only paths: SOL, TON).
ECDSAPubKey []byte `json:"ecdsa_pub_key,omitempty"`
// EDDSAPubKeyHex is the hex-encoded 32-byte Ed25519 public key.
// Used by TON (where the address is derived from the wallet
// contract's state_init cell, which embeds this pubkey). Empty
// for non-Ed25519 wallets. SOL also signs with Ed25519 but the
// address IS the pubkey base58-encoded, so SOL doesn't need this
// field — the address slot carries everything.
EDDSAPubKeyHex string `json:"eddsa_pub_key_hex,omitempty"`
}
// LegacyDepositString returns the "wallet_name###address" string the
// TS SDK and legacy Express server use as the on-the-wire
// representation of a deposit address. The SDK splits on `###` —
// preserve that contract on the proxy-to-SDK boundary.
func (w *Wallet) LegacyDepositString() string {
if w == nil {
return ""
}
return w.Name + "###" + w.Address
}
// MPCError surfaces keygen failures distinct from generic errors so
// callers can inspect Code/HTTPStatus and translate to HTTP responses.
type MPCError struct {
Op string // e.g. "keygen"
HTTPStatus int // 0 if the error didn't involve an HTTP response
Message string
}
func (e *MPCError) Error() string {
if e.HTTPStatus != 0 {
return fmt.Sprintf("mchain: %s HTTP %d: %s", e.Op, e.HTTPStatus, e.Message)
}
return fmt.Sprintf("mchain: %s: %s", e.Op, e.Message)
}
// ErrSubstrateNotImplemented historically signalled "DOT keygen
// unsupported by the bridge". As of the DOT integration the bridge
// derives the SS58 address client-side from the cluster's
// ecdsa_pub_key, so this error is no longer returned by KeygenForDeposit.
// Kept exported for callers that historically branched on errors.Is —
// they'll never see it return now, but the symbol stays a stable
// part of the API.
//
// Deprecated: as of bridge v2.x.x DOT is supported; do not branch on
// errors.Is against this value any more.
var ErrSubstrateNotImplemented = errors.New(
"mchain: substrate (dot) address derivation not implemented; needs SS58 encoder",
)
// ErrMissingPubKey is returned when a substrate (DOT) keygen succeeds
// but the cluster didn't populate ecdsa_pub_key — substrate ECDSA
// AccountId derivation needs the compressed pubkey. Surfaces clearly
// so the operator knows the MPC cluster's response shape changed.
var ErrMissingPubKey = errors.New(
"mchain: substrate keygen response missing ecdsa_pub_key — cluster cannot derive SS58 address",
)
// =============================================================================
// Client
// =============================================================================
// DefaultKeygenTimeout matches the 120s timeout in mpc-wallet.ts.
// Keygen rounds can be slow (multi-round MPC protocol over WAN), so
// the default is generous; tighten in tests.
const DefaultKeygenTimeout = 120 * time.Second
// Client is a Go client for the MPC keygen REST API. Construct one
// with New(...) or by populating fields on a zero-value Client.
type Client struct {
// APIURL is the MPC service base URL (e.g.
// `http://mpc-node-0.mpc-node-headless.lux-mpc.svc:9800`). The
// /keygen endpoint is appended automatically. Required.
APIURL string
// Token is the internal API bearer token. The mpcd daemon protects
// every endpoint except /health and /healthz behind
// `Authorization: Bearer <token>` (see mpcd main.go:441).
// Source the value from MPC_INTERNAL_API_KEY in production; in dev
// it can be derived from the node identity via DeriveInternalKey.
// Empty Token means no Authorization header — works only against
// daemons started without auth.
Token string
// OrgID is the default tenant identifier the daemon multiplexes
// keygen requests by. Required by mpcd; if empty, KeygenForDeposit
// returns an MPCError early rather than letting the cluster 400.
// Override per-call via KeygenForDepositWithOrg.
OrgID string
// Timeout bounds each individual keygen call. Zero means
// DefaultKeygenTimeout. The caller's context.Context is also honored.
Timeout time.Duration
// HTTPClient is the underlying http.Client. Zero means
// http.DefaultClient (no per-request timeout from the client
// itself; Client.Timeout supplies that via context).
HTTPClient *http.Client
// Clock is the time source for wallet-id construction. Tests
// override; production leaves it nil.
Clock func() time.Time
// DashboardURL points at the lux-mpc dashboard listener (typically
// port 8081). When set, SignForWallet routes through
// POST /v1/mpc/sign there instead of the legacy ${APIURL}/sign
// path (which the live mpcd v2026-05 daemon does NOT expose on
// the internal port). EnsureDashboardSession lazily mints a
// signing session on first use per (wallet, org).
DashboardURL string
// DashboardToken is a JWT for the dashboard API. Bridge gets one
// via /v1/auth/login or /v1/auth/oidc and refreshes it itself;
// pass the most-recent value via this field. Mutually exclusive
// with DashboardAPIKey — Bearer wins if both are set.
DashboardToken string
// DashboardAPIKey is an X-API-Key for the dashboard API. Bridge
// callers without OIDC plumbing can mint an API key once on the
// MPC dashboard with permissions=["sign"] and pass it here.
DashboardAPIKey string
// sessions caches dashboard session IDs by (walletID, orgID).
// Lazily initialized — sessionsOnce guards the first access.
sessions *sessionCache
sessionsOnce sync.Once
}
// New constructs a Client pointing at apiURL with no Bearer token and
// no org id. Suitable for dev clusters started without auth. For prod,
// prefer NewAuthed or populate Token + OrgID on a zero-value Client.
func New(apiURL string, timeout time.Duration) *Client {
return &Client{APIURL: apiURL, Timeout: timeout}
}
// NewAuthed constructs an auth-ready Client. token + orgID are
// required by the live mpcd daemon (see /root/luxify/mpc/cmd/mpcd/main.go
// line 441 onwards for the auth middleware; line 599 for the keygen
// request shape).
func NewAuthed(apiURL, token, orgID string, timeout time.Duration) *Client {
return &Client{
APIURL: apiURL,
Token: token,
OrgID: orgID,
Timeout: timeout,
}
}
// DeriveInternalKey computes the deterministic internal API key the
// mpcd daemon derives when MPC_INTERNAL_API_KEY is not set:
//
// SHA-256(ed25519 seed || "mpc-internal-api"), hex-encoded.
//
// `identityJSON` is the raw bytes of `${keysDir}/${nodeID}_identity.json`
// (the file written by mpcd's loadOrGenerateIdentity). Returns the hex
// string suitable for use as `Authorization: Bearer <key>`.
//
// Convenience for local dev where the operator can read the identity
// file directly. Production deployments should set MPC_INTERNAL_API_KEY
// on every node and pass it to the client via Token instead.
func DeriveInternalKey(identityJSON []byte) (string, error) {
var id struct {
PrivateKey string `json:"private_key"`
}
if err := json.Unmarshal(identityJSON, &id); err != nil {
return "", fmt.Errorf("mchain: parse identity json: %w", err)
}
priv, err := hex.DecodeString(id.PrivateKey)
if err != nil {
return "", fmt.Errorf("mchain: decode private_key hex: %w", err)
}
if len(priv) != 64 { // ed25519.PrivateKeySize
return "", fmt.Errorf("mchain: unexpected private_key length %d (want 64)", len(priv))
}
seed := priv[:32] // ed25519.PrivateKey.Seed()
sum := sha256.Sum256(append(seed, []byte("mpc-internal-api")...))
return hex.EncodeToString(sum[:]), nil
}
func (c *Client) httpClient() *http.Client {
if c.HTTPClient != nil {
return c.HTTPClient
}
return http.DefaultClient
}
func (c *Client) timeout() time.Duration {
if c.Timeout > 0 {
return c.Timeout
}
return DefaultKeygenTimeout
}
func (c *Client) now() time.Time {
if c.Clock != nil {
return c.Clock()
}
return time.Now()
}
// buildWalletID matches mpc-wallet.ts:
// `bridge-${networkInternalName.toLowerCase()}-${Date.now()}`.
// Date.now() in JS is milliseconds-since-epoch; we match that exactly
// so two implementations can produce identical ids given the same clock.
func (c *Client) buildWalletID(networkInternalName string) string {
return fmt.Sprintf("bridge-%s-%d",
strings.ToLower(networkInternalName),
c.now().UnixMilli(),
)
}
// =============================================================================
// KeygenForDeposit
// =============================================================================
// KeygenForDeposit triggers a fresh MPC keygen on the cluster and
// returns a *Wallet containing the chain-appropriate address. Uses
// Client.OrgID as the tenant identifier; for per-call override use
// KeygenForDepositWithOrg.
//
// This is the Go port of `createMPCWalletForDeposit` in mpc-wallet.ts,
// updated to match the live mpcd contract:
//
// POST `${APIURL}/keygen` with `{org_id, wallet_id}` body and
// `Authorization: Bearer <Token>` header.
//
// Errors:
// - *MPCError{Op:"keygen", Message:"OrgID not configured"} when Client.OrgID is empty.
// - *MPCError when the HTTP transport fails or the cluster returns
// a non-2xx response. Inspect .HTTPStatus.
// - *MPCError with Op="keygen" + Message="result_type=..." when the
// cluster's MPCKeygenResult.result_type is set and not "success".
// - ErrSubstrateNotImplemented for DOT requests.
// - context.Canceled / context.DeadlineExceeded when the caller's
// ctx (or the per-request timeout) fires first.
func (c *Client) KeygenForDeposit(ctx context.Context, networkInternalName string) (*Wallet, error) {
return c.KeygenForDepositWithOrg(ctx, networkInternalName, c.OrgID)
}
// KeygenForDepositWithOrg is KeygenForDeposit with an explicit per-call
// orgID, useful for multi-tenant proxies that route by request header
// or path segment.
func (c *Client) KeygenForDepositWithOrg(ctx context.Context, networkInternalName, orgID string) (*Wallet, error) {
if c.APIURL == "" {
return nil, &MPCError{Op: "keygen", Message: "APIURL not configured"}
}
// Refuse rather than guess. Minting a deposit wallet for a network whose
// address format we do not know produces an address on the wrong chain,
// which is discovered when a user sends funds to it.
addrType, known := AddressTypeFor(networkInternalName)
if !known {
return nil, &MPCError{
Op: "keygen",
Message: "no address format for network " + networkInternalName,
}
}
if orgID == "" {
return nil, &MPCError{Op: "keygen", Message: "org_id required (set Client.OrgID or pass per-call)"}
}
walletID := c.buildWalletID(networkInternalName)
curve := CurveFor(addrType)
body, err := json.Marshal(map[string]string{
"org_id": orgID,
"wallet_id": walletID,
// curve switches the cluster between ECDSA/secp256k1 and
// Ed25519/FROST. The live mpcd daemon ignores unknown keys, so
// older deployments that don't yet honour curve will fall back
// to their compile-time default (secp256k1) — that produces an
// EVM-shaped keygen which the SOL address picker then rejects
// downstream with a clear error. New deployments honour the
// hint and return an eddsa_pub_key + sol_address slot.
"curve": string(curve),
})
if err != nil {
return nil, fmt.Errorf("mchain: marshal keygen body: %w", err)
}
callCtx, cancel := context.WithTimeout(ctx, c.timeout())
defer cancel()
req, err := http.NewRequestWithContext(callCtx, http.MethodPost,
strings.TrimRight(c.APIURL, "/")+"/keygen",
bytes.NewReader(body),
)
if err != nil {
return nil, fmt.Errorf("mchain: build keygen request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
resp, err := c.httpClient().Do(req)
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return nil, err
}
return nil, &MPCError{Op: "keygen", Message: err.Error()}
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, &MPCError{
Op: "keygen",
HTTPStatus: resp.StatusCode,
Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, truncate(respBody, 256)),
}
}
var result keygenResult
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, &MPCError{
Op: "keygen",
HTTPStatus: resp.StatusCode,
Message: fmt.Sprintf("decode response: %v (body=%s)", err, truncate(respBody, 200)),
}
}
if result.ResultType != "" && result.ResultType != "success" {
msg := result.Error
if msg == "" {
msg = "unknown"
}
return nil, &MPCError{
Op: "keygen",
Message: fmt.Sprintf("result_type=%s: %s", result.ResultType, msg),
}
}
address, err := pickAddress(&result, addrType, networkInternalName)
if err != nil {
return nil, err
}
// The cluster echoes back its own wallet_id; if it's empty fall
// back to the one we sent so the SDK's `name###address` contract
// always has a non-empty name slot.
name := result.WalletID
if name == "" {
name = walletID
}
// Decode the compressed ECDSA pubkey for downstream consumers
// (BTC release flow needs it for the witness stack; XRP release
// flow embeds it in the Payment's SigningPubKey; DOT signing
// derives AccountId32). Best-effort: if the keygen didn't return
// one (ed25519-only path), leave nil.
var ecdsaPubKey []byte
if result.ECDSAPubKey != "" {
if decoded, derr := hex.DecodeString(strings.TrimPrefix(result.ECDSAPubKey, "0x")); derr == nil {
ecdsaPubKey = compressSecp256k1Pubkey(decoded)
}
}
return &Wallet{
Name: name,
Address: address,
AddressType: addrType,
ECDSAPubKey: ecdsaPubKey,
EDDSAPubKeyHex: result.EDDSAPubKey,
}, nil
}
// =============================================================================
// SignForWallet — threshold signing for a previously-keygen'd wallet
// =============================================================================
// SignResult is the success payload returned by the /sign endpoint.
// Shape mirrors the keygen response style: `result_type=success` →
// `signature` + `session_id` populated; otherwise `error` + `error_code`.
type SignResult struct {
Signature string `json:"signature"`
SessionID string `json:"session_id"`
}
// signResultWire is the on-wire envelope. Internal — callers consume
// *SignResult through SignForWallet.
type signResultWire struct {
WalletID string `json:"wallet_id"`
Signature string `json:"signature"`
SessionID string `json:"session_id"`
ResultType string `json:"result_type"`
Error string `json:"error"`
}
// SignForWallet requests a threshold signature on `messageHex` from
// the MPC cluster for the previously-generated wallet identified by
// `walletID`. messageHex is the hex-encoded message digest the
// cluster will sign (typically a 32-byte hash for ECDSA / Ed25519).
//
// Returns *SignResult on success. *MPCError on transport / auth /
// result_type failures. context errors on cancellation / timeout.
//
// Wire shape (mirrors /keygen):
//
// POST `${APIURL}/sign`
// Authorization: Bearer <Token>
// Content-Type: application/json
// {"org_id":"...","wallet_id":"...","message":"<hex>"}
//
// Response on success: {wallet_id, signature, session_id, result_type:"success"}
// Response on cluster-side failure: {wallet_id, error, error_code, result_type:"error"}
//
// Note: as of 2026-05, the live lux-mpc daemon (mpcd) exposes /keygen
// on the internal API port (:6000) but NOT /sign — the dashboard API
// (port 8081, gated on MPC_JWT_SECRET) hosts signing under
// /v1/mpc/wallets/{id}/sessions. This method targets the simpler /sign
// shape that the cluster is expected to grow; callers can point Client
// at a custom URL if their deployment routes differently.
func (c *Client) SignForWallet(ctx context.Context, walletID, messageHex string) (*SignResult, error) {
return c.SignForWalletWithOrg(ctx, walletID, messageHex, c.OrgID)
}
// SignForWalletOnCurve is SignForWallet with an explicit curve hint. The
// caller (signing driver) knows the destination address family and so
// the curve. The hint is forwarded to the dashboard so it routes the
// session to the matching threshold-signature stack: ECDSA/secp256k1
// for ETH/BTC, EdDSA/Ed25519 (FROST) for SOL/TON/DOT/Cardano.
//
// On the wire: an extra "curve" field in the POST body. Older
// dashboards ignore unknown fields, so the call degrades to whatever
// the cluster's default curve is (secp256k1) — that returns a 65-byte
// ECDSA signature which the SOL finalizer will reject with a clear
// error.
func (c *Client) SignForWalletOnCurve(ctx context.Context, walletID, messageHex string, curve Curve) (*SignResult, error) {
return c.SignForWalletOnCurveWithOrg(ctx, walletID, messageHex, c.OrgID, curve)
}
// SignForWalletWithOrg is SignForWallet with an explicit per-call orgID.
func (c *Client) SignForWalletWithOrg(ctx context.Context, walletID, messageHex, orgID string) (*SignResult, error) {
return c.SignForWalletOnCurveWithOrg(ctx, walletID, messageHex, orgID, CurveSecp256k1)
}
// SignForWalletOnProtocol is SignForWalletOnCurve with an explicit
// Protocol hint (PQ-safe pulsar / corona, or a different classical
// protocol than the daemon's curve-default). Use this when the bridge
// profile pins a specific threshold-signature stack.
//
// On the wire: an extra "protocol" field is added to the POST body
// only when Protocol != ProtocolDefault. Older dashboards ignore the
// field and silently fall back to curve dispatch — the safe degradation
// when the cluster is mid-rollout of a new PQ protocol.
func (c *Client) SignForWalletOnProtocol(ctx context.Context, walletID, messageHex string, curve Curve, protocol Protocol) (*SignResult, error) {
return c.SignForWalletOnProtocolWithOrg(ctx, walletID, messageHex, c.OrgID, curve, protocol)
}
// SignForWalletOnCurveWithOrg is the curve-only variant — kept for
// callers that don't know about Protocol. Equivalent to
// SignForWalletOnProtocolWithOrg with ProtocolDefault.
func (c *Client) SignForWalletOnCurveWithOrg(ctx context.Context, walletID, messageHex, orgID string, curve Curve) (*SignResult, error) {
return c.SignForWalletOnProtocolWithOrg(ctx, walletID, messageHex, orgID, curve, ProtocolDefault)
}
// SignForWalletOnProtocolWithOrg is the canonical implementation. All
// other Sign* variants funnel through here. Curve defaults to
// CurveSecp256k1 for backward compat; Protocol defaults to
// ProtocolDefault, which omits the field on the wire and lets the
// daemon pick by curve (cggmp21 for secp256k1, frost for ed25519).
func (c *Client) SignForWalletOnProtocolWithOrg(ctx context.Context, walletID, messageHex, orgID string, curve Curve, protocol Protocol) (*SignResult, error) {
if walletID == "" {
return nil, &MPCError{Op: "sign", Message: "wallet_id required"}
}
if messageHex == "" {
return nil, &MPCError{Op: "sign", Message: "message required (hex-encoded)"}
}
if orgID == "" {
return nil, &MPCError{Op: "sign", Message: "org_id required (set Client.OrgID or pass per-call)"}
}
// Production path: dashboard API. The live mpcd v2026-05 daemon
// only serves /sign through the dashboard listener (port 8081),
// gated by JWT or X-API-Key. EnsureDashboardSession lazily mints
// a per-(wallet, org) signing grant that signViaDashboard reuses.
if c.DashboardSigning() {
return c.signViaDashboard(ctx, walletID, messageHex, orgID, curve, protocol)
}
// Legacy path: ${APIURL}/sign — kept for in-process mocks and any
// future internal-API rev that grows a /sign route. The live
// cluster does NOT serve this; configuring only APIURL is a
// misconfiguration we surface clearly.
if c.APIURL == "" {
return nil, &MPCError{Op: "sign", Message: "neither APIURL nor DashboardURL configured"}
}
legacyBody := map[string]string{
"org_id": orgID,
"wallet_id": walletID,
"message": messageHex,
// curve is forwarded so legacy mocks can branch on it. Production
// runs through the dashboard path above.
"curve": string(curve),
}
// Same opt-in semantic as the dashboard path: omit "protocol" when
// ProtocolDefault so legacy mocks that pre-date the enum keep
// receiving the exact body shape they were written against.
if protocol != ProtocolDefault {
legacyBody["protocol"] = string(protocol)
}
body, err := json.Marshal(legacyBody)
if err != nil {
return nil, fmt.Errorf("mchain: marshal sign body: %w", err)
}
callCtx, cancel := context.WithTimeout(ctx, c.timeout())
defer cancel()
req, err := http.NewRequestWithContext(callCtx, http.MethodPost,
strings.TrimRight(c.APIURL, "/")+"/sign",
bytes.NewReader(body),
)
if err != nil {
return nil, fmt.Errorf("mchain: build sign request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
resp, err := c.httpClient().Do(req)
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return nil, err
}
return nil, &MPCError{Op: "sign", Message: err.Error()}
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, &MPCError{
Op: "sign",
HTTPStatus: resp.StatusCode,
Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, truncate(respBody, 256)),
}
}
var result signResultWire
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, &MPCError{
Op: "sign",
HTTPStatus: resp.StatusCode,
Message: fmt.Sprintf("decode response: %v (body=%s)", err, truncate(respBody, 200)),
}
}
if result.ResultType != "" && result.ResultType != "success" {
msg := result.Error
if msg == "" {
msg = "unknown"
}
return nil, &MPCError{
Op: "sign",
Message: fmt.Sprintf("result_type=%s: %s", result.ResultType, msg),
}
}
if result.Signature == "" {
return nil, &MPCError{
Op: "sign",
Message: "no signature returned from MPC cluster",
}
}
return &SignResult{
Signature: result.Signature,
SessionID: result.SessionID,
}, nil
}
// pickAddress mirrors the TS switch on AddressType. Returns an
// *MPCError when the chosen slot is empty.
//
// networkInternalName is required for BTC so the bech32 hrp matches the
// destination (BITCOIN_MAINNET → "bc"; BITCOIN_TESTNET → "tb") and for
// substrate / DOT so the SS58 prefix matches the network. For every
// other family it's currently ignored but threaded so the API is uniform.
//
// BTC handling: mpcd's btc_address field is a legacy P2PKH "1..." Base58
// address. The bridge release flow uses bech32 P2WPKH, derived locally
// from the ECDSAPubKey returned alongside. If the keygen returns an
// ECDSAPubKey we ALWAYS prefer the locally-derived P2WPKH; otherwise we
// fall back to the legacy form so SDK callers that read the wire
// `btc_address` slot still get something.
//
// DOT handling: Substrate ECDSA AccountId = blake2_256(compressed_pubkey),
// then SS58-encoded with the network-specific prefix (Polkadot mainnet
// vs Kusama vs Westend/generic testnet).
func pickAddress(r *keygenResult, t AddressType, networkInternalName string) (string, error) {
var addr string
switch t {
case AddressTypeBTC:
// Prefer locally-derived bech32 P2WPKH from the ECDSAPubKey.
if derived, derr := deriveBTCBech32Address(r.ECDSAPubKey, networkInternalName); derr == nil && derived != "" {
addr = derived
} else {
addr = r.BTCAddress
}
case AddressTypeTON:
// TON v4r2 address = state_init_hash(workchain=0, subwallet, pubkey).
// Prefer the proper derivation when the cluster surfaced the
// Ed25519 pubkey; otherwise fall back to whatever the cluster
// put in the SOL slot (legacy mpcd that doesn't yet derive
// chain-specific addresses).
if r.EDDSAPubKey != "" {
derived, err := deriveTONAddressFromHex(r.EDDSAPubKey)
if err == nil {
return derived, nil
}
// On derivation failure, fall through to the legacy slot —
// don't refuse keygens just because the new path glitched.
}
addr = r.SOLAddress
case AddressTypeSOL:
addr = r.SOLAddress
case AddressTypeXRP:
// XRP r-address derivation: take the compressed-secp256k1 ECDSA
// pubkey returned by the MPC cluster, RIPEMD160(SHA256(pub)) →
// AccountID, then base58check with the XRPL alphabet + 0x00
// version byte. The cluster doesn't surface an r-address slot
// directly (XRPL never had its own keygen response field) but
// the ECDSAPubKey IS the pubkey that controls the XRP account,
// so derivation here is canonical and reversible.
//
// When the cluster's ECDSA pubkey field is empty (legacy mocks /
// shape-only test fixtures), fall back to ETH address as a stable
// identifier so the cluster integration tests pass. Production
// keygen always populates ecdsa_pub_key.
if r.ECDSAPubKey != "" {
pubHex := strings.TrimPrefix(strings.TrimPrefix(r.ECDSAPubKey, "0x"), "0X")
pub, err := hex.DecodeString(pubHex)
if err != nil {
return "", &MPCError{
Op: "keygen",
Message: fmt.Sprintf("decode ECDSAPubKey hex %q: %v", r.ECDSAPubKey, err),
}
}
rAddr, err := xrpl.AddressFromPubKey(pub)
if err != nil {
return "", &MPCError{
Op: "keygen",
Message: fmt.Sprintf("derive XRP r-address: %v", err),
}
}
addr = rAddr
} else {
addr = r.ETHAddress
}
case AddressTypeDOT:
// Substrate ECDSA AccountId = blake2_256(compressed_pubkey),
// then SS58-encoded with the network-specific prefix. Derive
// client-side from the ecdsa_pub_key the cluster returns —
// the mpcd daemon doesn't (yet) emit SS58 strings natively.
if r.ECDSAPubKey == "" {
return "", ErrMissingPubKey
}
ss58, err := deriveSubstrateSS58(r.ECDSAPubKey, networkInternalName)
if err != nil {
return "", &MPCError{
Op: "keygen",
Message: fmt.Sprintf("derive SS58 address: %v", err),
}
}
addr = ss58
default: // AddressTypeETH or unknown → eth
addr = preferEVMAddress(r)
}
if addr == "" {
return "", &MPCError{
Op: "keygen",
Message: fmt.Sprintf("no %s address returned from MPC keygen", t),
}
}
return addr, nil
}
func truncate(b []byte, n int) string {
if len(b) <= n {
return string(b)
}
return string(b[:n]) + "…"
}