mirror of
https://github.com/luxfi/mpc.git
synced 2026-09-24 00:26:24 +00:00
Solana addresses were correct and receive-only: ConsensusKeyInfoStore.Save was the only RegisterKey caller and hardcoded "secp256k1", and TriggerSign read the curve from that field, so no product entry point could reach Ed25519 signing. The hardcode is not fixed, it is UNREPRESENTABLE. transport.KeyInfo.KeyType is deleted — the record carries one public key per curve and a PublicKeyFor(curve) — and RegisterKey lost its keyType parameter, so there is no longer a place to state a curve wrongly. A scalar KeyType was the wrong shape anyway: one keygen ceremony produces BOTH a secp256k1 and an Ed25519 key, so "what curve is this wallet" has no single answer. The request carries the network instead, and ONE table maps it to a curve, with NO default arm — KeyTypeForNetwork returns ErrUnknownNetwork rather than guessing. That is the point: the old default returned secp256k1 for anything unrecognized, which is how a Solana request would have been signed with the wrong key. /sign's 32-byte clamp becomes the SCHEME's rule rather than the endpoint's: secp256k1 signs a digest and requires exactly 32 bytes, Ed25519 signs the message at any length. /sign no longer accepts key_type at all — two ways to say the curve is the misuse being removed — and Network is bound into the idempotency fingerprint, so the same bytes for SOL and ETH conflict instead of sharing one cached signature from a different key. ⚠ The trap this nearly shipped with: every pre-existing KeyInfo record has EMPTY public keys, because the old Save wrote "", "". A naive "does this wallet have this curve" gate would have refused every existing wallet on every curve. The check is gated on RecordsCurves() — a record naming no curve denies none, and the missing share stays the thing that fails closed. Verified end to end, not by mirroring: a Solana request through ConsensusMPCBackend.TriggerSign with a real 2-of-3 FROST ring produces a signature crypto/ed25519.Verify accepts, over a 63-byte message so any reintroduced clamp or pre-hash fails it; ETH/EVM/BTC/LUX still route to secp256k1 and must RECOVER to the wallet's key. Four mutations tried, all caught. Behavioral risk worth naming: an unrecognized chain string now fails its transaction closed rather than signing with a default. That is correct, and the real fix is validating chain at POST /v1/transactions.
217 lines
6.3 KiB
Go
217 lines
6.3 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/hanzoai/orm"
|
|
"golang.org/x/crypto/sha3"
|
|
|
|
"github.com/luxfi/mpc/pkg/db"
|
|
"github.com/luxfi/mpc/pkg/types"
|
|
)
|
|
|
|
// Bridge signing request — matches the bridge server's expected format.
|
|
type bridgeSignRequest struct {
|
|
TxID string `json:"txId"`
|
|
FromNetworkID string `json:"fromNetworkId"`
|
|
ToNetworkID string `json:"toNetworkId"`
|
|
ToTokenAddress string `json:"toTokenAddress"`
|
|
MsgSignature string `json:"msgSignature"`
|
|
ReceiverAddrHash string `json:"receiverAddressHash"`
|
|
}
|
|
|
|
// Bridge signing response — matches the bridge server's expected format.
|
|
type bridgeSignResponse struct {
|
|
Status bool `json:"status"`
|
|
Msg string `json:"msg,omitempty"`
|
|
Data *bridgeSignData `json:"data,omitempty"`
|
|
}
|
|
|
|
type bridgeSignData struct {
|
|
FromTokenAddress string `json:"fromTokenAddress"`
|
|
Contract string `json:"contract"`
|
|
From string `json:"from"`
|
|
TokenAmount string `json:"tokenAmount"`
|
|
Signature string `json:"signature"`
|
|
MPCSigner string `json:"mpcSigner"`
|
|
HashedTxID string `json:"hashedTxId"`
|
|
ToTokenAddrHash string `json:"toTokenAddressHash"`
|
|
Vault bool `json:"vault"`
|
|
}
|
|
|
|
// handleBridgeSign handles POST /v1/generate_mpc_sig
|
|
func (s *Server) handleBridgeSign(w http.ResponseWriter, r *http.Request) {
|
|
var req bridgeSignRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
json.NewEncoder(w).Encode(bridgeSignResponse{Status: false, Msg: "invalid request body"})
|
|
return
|
|
}
|
|
|
|
if req.TxID == "" || req.FromNetworkID == "" || req.ToNetworkID == "" {
|
|
json.NewEncoder(w).Encode(bridgeSignResponse{Status: false, Msg: "missing required fields"})
|
|
return
|
|
}
|
|
|
|
// Replay protection: use txId as idempotency key (timestamp=0 skips age check
|
|
// since bridge requests don't carry a timestamp field)
|
|
if reason := s.replayGuard.check("bridge:"+req.TxID, 0); reason != "" {
|
|
json.NewEncoder(w).Encode(bridgeSignResponse{Status: false, Msg: reason})
|
|
return
|
|
}
|
|
|
|
// Check MPC cluster health
|
|
status := s.mpc.GetClusterStatus()
|
|
if status == nil || !status.Ready {
|
|
json.NewEncoder(w).Encode(bridgeSignResponse{Status: false, Msg: "MPC cluster not ready"})
|
|
return
|
|
}
|
|
|
|
// Find a bridge wallet — look for a wallet named "bridge" or use the first active secp256k1 wallet
|
|
var bridgeWallet *db.Wallet
|
|
orgID := getOrgID(r.Context())
|
|
|
|
// Try to find wallet with "bridge" in name
|
|
wallets, err := orm.TypedQuery[db.Wallet](s.db.ORM).
|
|
Filter("orgId=", orgID).
|
|
Filter("status=", "active").
|
|
Filter("keyType=", "secp256k1").
|
|
Order("createdAt").
|
|
Limit(10).
|
|
GetAll(r.Context())
|
|
if err == nil {
|
|
for _, w := range wallets {
|
|
if w.Name != nil && contains(*w.Name, "bridge") {
|
|
bridgeWallet = w
|
|
break
|
|
}
|
|
}
|
|
if bridgeWallet == nil && len(wallets) > 0 {
|
|
bridgeWallet = wallets[0]
|
|
}
|
|
}
|
|
|
|
if bridgeWallet == nil {
|
|
json.NewEncoder(w).Encode(bridgeSignResponse{Status: false, Msg: "no signing wallet available"})
|
|
return
|
|
}
|
|
|
|
mpcWalletID := bridgeWallet.WalletID
|
|
evmAddr := ""
|
|
if bridgeWallet.EVMAddress != nil {
|
|
evmAddr = *bridgeWallet.EVMAddress
|
|
}
|
|
|
|
// Hash the transaction ID (keccak256)
|
|
hashedTxID := keccak256Hex(req.TxID)
|
|
|
|
// Create the message to sign: keccak256(txId)
|
|
msgHash, err := hex.DecodeString(hashedTxID[2:]) // strip 0x
|
|
if err != nil {
|
|
json.NewEncoder(w).Encode(bridgeSignResponse{Status: false, Msg: "failed to hash tx"})
|
|
return
|
|
}
|
|
|
|
// Trigger MPC signing. The bridge signs a keccak256 digest that only an
|
|
// EVM contract verifies, so the network is a property of this endpoint
|
|
// rather than of the request — no non-EVM payload can arrive here.
|
|
result, err := s.mpc.TriggerSign(orgID, mpcWalletID, types.NetworkEVM, msgHash)
|
|
if err != nil {
|
|
json.NewEncoder(w).Encode(bridgeSignResponse{Status: false, Msg: "MPC signing failed: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
// Build signature: concatenate r + s + v
|
|
sig := result.R + result.S
|
|
if result.Signature != "" {
|
|
sig = result.Signature
|
|
}
|
|
|
|
// Record the bridge transaction in DB
|
|
if orgID != "" {
|
|
now := time.Now()
|
|
tx := orm.New[db.Transaction](s.db.ORM)
|
|
tx.OrgID = orgID
|
|
tx.TxType = "bridge_sign"
|
|
tx.Chain = req.ToNetworkID
|
|
tx.ToAddress = nilIfEmpty(req.ReceiverAddrHash)
|
|
tx.Status = "signed"
|
|
tx.TxHash = nilIfEmpty(hashedTxID)
|
|
tx.SignatureR = nilIfEmpty(result.R)
|
|
tx.SignatureS = nilIfEmpty(result.S)
|
|
tx.SignedAt = &now
|
|
tx.Create()
|
|
}
|
|
|
|
toTokenAddrHash := keccak256Hex(req.ToTokenAddress)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(bridgeSignResponse{
|
|
Status: true,
|
|
Data: &bridgeSignData{
|
|
FromTokenAddress: req.ToTokenAddress,
|
|
Signature: sig,
|
|
MPCSigner: evmAddr,
|
|
HashedTxID: hashedTxID,
|
|
ToTokenAddrHash: toTokenAddrHash,
|
|
Vault: false,
|
|
},
|
|
})
|
|
}
|
|
|
|
// handleBridgeComplete handles POST /v1/complete
|
|
func (s *Server) handleBridgeComplete(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
HashedTxID string `json:"hashedTxId"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]interface{}{"status": false, "msg": "invalid request"})
|
|
return
|
|
}
|
|
|
|
if req.HashedTxID == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]interface{}{"status": false, "msg": "hashedTxId required"})
|
|
return
|
|
}
|
|
|
|
// Mark the bridge transaction as completed
|
|
orgID := getOrgID(r.Context())
|
|
if orgID != "" {
|
|
txList, err := orm.TypedQuery[db.Transaction](s.db.ORM).
|
|
Filter("orgId=", orgID).
|
|
Filter("txType=", "bridge_sign").
|
|
Filter("txHash=", req.HashedTxID).
|
|
Limit(1).
|
|
GetAll(r.Context())
|
|
if err == nil && len(txList) > 0 {
|
|
now := time.Now()
|
|
tx := txList[0]
|
|
tx.Status = "confirmed"
|
|
tx.BroadcastAt = &now
|
|
tx.Update()
|
|
}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"status": true, "msg": "success"})
|
|
}
|
|
|
|
// keccak256Hex returns 0x-prefixed keccak256 hash of input string.
|
|
func keccak256Hex(input string) string {
|
|
h := sha3.NewLegacyKeccak256()
|
|
h.Write([]byte(input))
|
|
return "0x" + hex.EncodeToString(h.Sum(nil))
|
|
}
|
|
|
|
// contains checks if s contains substr (case-insensitive done simply via strings import).
|
|
func contains(s, substr string) bool {
|
|
// Simple case-sensitive check; good enough for wallet name matching
|
|
for i := 0; i <= len(s)-len(substr); i++ {
|
|
if s[i:i+len(substr)] == substr {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|