mirror of
https://github.com/luxfi/mpc.git
synced 2026-09-24 00:26:24 +00:00
luxfi/threshold v1.12.7 added FROST(Ed25519, SHA-512), so Solana and TON finally have a signable curve. The sessions named "EdDSA" were calling KeygenTaproot — BIP-340 over secp256k1 — whose key is also 32 bytes, sails through the len(EDDSAPubKey)==32 gate, and base58-encodes into a real-looking Solana address the ring can never sign for. ⚠ A LIVE WRONG-ADDRESS BUG went with it: handlers_wallets served addresses["ton"] = *wallet.SolAddress. A TON address is not a base58 pubkey — it is workchain plus a hash of the wallet contract's StateInit, base64url with a CRC16 — so the API was handing out an address that decodes to nothing. Deleted rather than guessed; TON returns when its encoding is real. The keygen tests used to call frost.KeygenEd25519 directly, which validated the LIBRARY and not the daemon: reintroducing KeygenTaproot left every package green. They now go through the production session, and so does a test that constructs the real signing session and proves the caller's bytes reach the protocol unhashed. Both original bugs reintroduced together fail 11 tests. secp256k1 keygen changes from fail-open to fail-closed, and that is the one behavioural change to the existing EVM/BTC path. An empty pubkey previously meant any of nil config, marshal failure, kvstore.Put failure or keyinfo-save failure — and was returned as SUCCESS, announcing addresses while fewer than t+1 nodes hold a share. Applying fail-closed to Solana while leaving the higher-value leg fail-open is incoherent. Also: the KMS share classifier still keyed off the retired "frost:" prefix; the CGGMP21 session published its own local failure onto the same first-wins subject as the ring's verdict, so one node's failure could beat the ring's success; and one 120s deadline shared across two sequential ceremonies starved the cheap leg. NOT YET SPENDABLE. ConsensusKeyInfoStore.Save is the only RegisterKey caller and hardcodes "secp256k1", and TriggerSign reads the curve from that field, so no product entry point can reach Ed25519 signing. The address is correct; signing it needs a wire-contract change shared with lux/wallet.
237 lines
7.1 KiB
Go
237 lines
7.1 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
|
|
"github.com/hanzoai/orm"
|
|
|
|
"github.com/luxfi/mpc/pkg/db"
|
|
)
|
|
|
|
func (s *Server) handleListWallets(w http.ResponseWriter, r *http.Request) {
|
|
orgID := getOrgID(r.Context())
|
|
userID := getUserID(r.Context())
|
|
vaultID := urlParam(r, "id")
|
|
|
|
q := orm.TypedQuery[db.Wallet](s.db.ORM).
|
|
Filter("vaultId=", vaultID).
|
|
Filter("orgId=", orgID).
|
|
Order("-createdAt")
|
|
|
|
// Per-user isolation: non-admin users only see their own wallets
|
|
role := getRole(r.Context())
|
|
if role != "owner" && role != "admin" && userID != "" {
|
|
q = q.Filter("createdBy=", userID)
|
|
}
|
|
|
|
wallets, err := q.GetAll(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "database error")
|
|
return
|
|
}
|
|
if wallets == nil {
|
|
wallets = []*db.Wallet{}
|
|
}
|
|
writeJSON(w, http.StatusOK, wallets)
|
|
}
|
|
|
|
func (s *Server) handleCreateWallet(w http.ResponseWriter, r *http.Request) {
|
|
orgID := getOrgID(r.Context())
|
|
userID := getUserID(r.Context())
|
|
vaultID := urlParam(r, "id")
|
|
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
KeyType string `json:"key_type"`
|
|
Protocol string `json:"protocol"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if req.KeyType == "" {
|
|
req.KeyType = "secp256k1"
|
|
}
|
|
if req.Protocol == "" {
|
|
req.Protocol = "cggmp21"
|
|
}
|
|
if req.Protocol != "cggmp21" && req.Protocol != "frost" && req.Protocol != "lss" && req.Protocol != "sr25519" {
|
|
writeError(w, http.StatusBadRequest, "invalid protocol: must be cggmp21, frost, lss, or sr25519")
|
|
return
|
|
}
|
|
|
|
// Verify vault belongs to org
|
|
vault, err := orm.Get[db.Vault](s.db.ORM, vaultID)
|
|
if err != nil || vault.OrgID != orgID {
|
|
writeError(w, http.StatusNotFound, "vault not found")
|
|
return
|
|
}
|
|
|
|
// Trigger MPC keygen
|
|
result, err := s.mpc.TriggerKeygen(orgID, "")
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "keygen failed: "+err.Error())
|
|
return
|
|
}
|
|
|
|
// Get cluster status for participants
|
|
status := s.mpc.GetClusterStatus()
|
|
participants := []string{}
|
|
if status != nil {
|
|
total := status.ExpectedPeers + 1
|
|
for i := 0; i < total; i++ {
|
|
participants = append(participants, fmt.Sprintf("node%d", i))
|
|
}
|
|
}
|
|
|
|
name := req.Name
|
|
wal := orm.New[db.Wallet](s.db.ORM)
|
|
wal.VaultID = vaultID
|
|
wal.OrgID = orgID
|
|
wal.WalletID = result.WalletID
|
|
wal.Name = nilIfEmpty(name)
|
|
wal.KeyType = req.KeyType
|
|
wal.Protocol = req.Protocol
|
|
wal.ECDSAPubkey = nilIfEmpty(result.ECDSAPubKey)
|
|
wal.EDDSAPubkey = nilIfEmpty(result.EDDSAPubKey)
|
|
wal.EVMAddress = nilIfEmpty(result.EVMAddress)
|
|
wal.BtcAddress = nilIfEmpty(result.BtcAddress)
|
|
wal.SolAddress = nilIfEmpty(result.SolAddress)
|
|
wal.Threshold = status.Threshold
|
|
wal.Participants = participants
|
|
wal.Version = 1
|
|
wal.Status = "active"
|
|
wal.CreatedBy = nilIfEmpty(userID)
|
|
if err := wal.Create(); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to save wallet: "+err.Error())
|
|
return
|
|
}
|
|
|
|
s.fireWebhook(r.Context(), orgID, "keygen.complete", wal)
|
|
writeJSON(w, http.StatusCreated, wal)
|
|
}
|
|
|
|
func (s *Server) handleGetWallet(w http.ResponseWriter, r *http.Request) {
|
|
orgID := getOrgID(r.Context())
|
|
walletID := urlParam(r, "id")
|
|
|
|
wallet, err := orm.Get[db.Wallet](s.db.ORM, walletID)
|
|
if err != nil || wallet.OrgID != orgID {
|
|
writeError(w, http.StatusNotFound, "wallet not found")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, wallet)
|
|
}
|
|
|
|
func (s *Server) handleGetWalletAddresses(w http.ResponseWriter, r *http.Request) {
|
|
orgID := getOrgID(r.Context())
|
|
walletID := urlParam(r, "id")
|
|
|
|
wallet, err := orm.Get[db.Wallet](s.db.ORM, walletID)
|
|
if err != nil || wallet.OrgID != orgID {
|
|
writeError(w, http.StatusNotFound, "wallet not found")
|
|
return
|
|
}
|
|
|
|
addresses := map[string]interface{}{}
|
|
if wallet.EVMAddress != nil {
|
|
// "ethereum"/"lux"/"xrp" are user-facing display labels keyed by
|
|
// chain name; the value is the 20-byte EVM-runtime account
|
|
// address (the form consumed by every EVM-compatible chain plus
|
|
// xrp's secp256k1-derived form). Map labels are display names,
|
|
// not internal naming.
|
|
addresses["ethereum"] = *wallet.EVMAddress
|
|
addresses["lux"] = *wallet.EVMAddress // EVM-compatible
|
|
addresses["xrp"] = *wallet.EVMAddress // secp256k1-derived (simplified)
|
|
}
|
|
if wallet.BtcAddress != nil {
|
|
addresses["bitcoin"] = *wallet.BtcAddress
|
|
}
|
|
if wallet.SolAddress != nil {
|
|
addresses["solana"] = *wallet.SolAddress
|
|
// No "ton" entry. TON shares this wallet's Ed25519 key but not its
|
|
// address format: a TON address is workchain ‖ SHA-256 of a wallet
|
|
// contract's StateInit cell, base64url with a CRC16, and it differs
|
|
// between contract versions. The Solana base58 string is not a TON
|
|
// address in any encoding, so serving it here published an address that
|
|
// can receive nothing and prove nothing. Absent until address.TON exists.
|
|
}
|
|
writeJSON(w, http.StatusOK, addresses)
|
|
}
|
|
|
|
func (s *Server) handleReshareWallet(w http.ResponseWriter, r *http.Request) {
|
|
orgID := getOrgID(r.Context())
|
|
walletID := urlParam(r, "id")
|
|
|
|
var req struct {
|
|
NewThreshold int `json:"new_threshold"`
|
|
NewParticipants []string `json:"new_participants"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
wallet, err := orm.Get[db.Wallet](s.db.ORM, walletID)
|
|
if err != nil || wallet.OrgID != orgID {
|
|
writeError(w, http.StatusNotFound, "wallet not found")
|
|
return
|
|
}
|
|
|
|
if err := s.mpc.TriggerReshare(orgID, wallet.WalletID, req.NewThreshold, req.NewParticipants); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "reshare failed: "+err.Error())
|
|
return
|
|
}
|
|
|
|
wallet.Version++
|
|
wallet.Threshold = req.NewThreshold
|
|
wallet.Participants = req.NewParticipants
|
|
if err := wallet.Update(); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to update wallet")
|
|
return
|
|
}
|
|
|
|
// After reshare, invalidate all existing backup shards. The old key share
|
|
// material is now stale — backups encrypted with the old shard are useless.
|
|
// The user must create a new backup with the reshared key material.
|
|
activeBackups, _ := orm.TypedQuery[db.WalletBackup](s.db.ORM).
|
|
Filter("orgId=", orgID).
|
|
Filter("walletId=", walletID).
|
|
Filter("status=", "active").
|
|
GetAll(r.Context())
|
|
for _, backup := range activeBackups {
|
|
backup.Status = "invalidated_by_reshare"
|
|
backup.Update()
|
|
}
|
|
|
|
s.writeSigningAuditLog(r.Context(), orgID, wallet.WalletID, "", "", "reshare")
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"status": "reshare_complete",
|
|
"backups_invalidated": len(activeBackups),
|
|
"action_required": "create new wallet backup with updated key share",
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleWalletHistory(w http.ResponseWriter, r *http.Request) {
|
|
orgID := getOrgID(r.Context())
|
|
walletID := urlParam(r, "id")
|
|
|
|
txs, err := orm.TypedQuery[db.Transaction](s.db.ORM).
|
|
Filter("walletId=", walletID).
|
|
Filter("orgId=", orgID).
|
|
Order("-createdAt").
|
|
Limit(100).
|
|
GetAll(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "database error")
|
|
return
|
|
}
|
|
if txs == nil {
|
|
txs = []*db.Transaction{}
|
|
}
|
|
writeJSON(w, http.StatusOK, txs)
|
|
}
|