mirror of
https://github.com/luxfi/mpc.git
synced 2026-09-24 00:26:24 +00:00
TON's wallet version is not a preference, it is FORCED. The account_id is a hash of the wallet contract's StateInit, so the same Ed25519 key yields a different address under v3R2, v4R2 and W5 — and luxfi/bridge's TON assembler is the only code in the estate that can SPEND TON. It builds v4R2 with subwallet 698983191. Emitting any other version would produce addresses that fund normally and that our own signer can never deploy a contract for: money in, unspendable. So this makes the identical call into the identical library rather than hand-rolling cell serialization. Non-bounceable (UQ…) because a bounceable message to a not-yet-deployed contract is returned to sender, and a deposit address is undeployed by definition until its first spend. Bridge makes the same choice. ⚠ SECOND LIVE WRONG-ADDRESS BUG, same shape as the TON/Solana one removed last week: handlers_wallets served addresses["xrp"] = *wallet.EVMAddress — a 20-byte hex EVM address labelled XRP. Both xrp and ton now derive from the stored public keys through pkg/address. Derived on READ rather than adding columns, so every wallet minted before today gets a correct address with no migration. Vectors are external on purpose. XRP matches three published XRPL fixtures (xrpl.js secp256k1 + ed25519, and the dev-portal address-encoding example). TON uses MAINNET as the oracle — library fixtures would be self-referential — by harvesting accounts whose code_hash is the v4R2 code hash and asking each contract for its own public key; one row is corroborated independently by @ton/ton's published spec address. The code cell is pinned to mainnet's hash and the data layout to seqno‖subwallet‖pubkey. Curve confusion fails closed both ways: TON refuses a secp256k1 key, XRP refuses an Ed25519 one, and TON refuses every torsion-order point Solana refuses. Known trap left alone deliberately: pubKeyToEVMAddress and pubKeyToBtcAddress still assume even-y for a 32-byte input, which is wrong for half of keys. Not reachable today because keygen serializes the 33-byte compressed point, but it is one refactor from being live. address.XRP refuses that input instead. Fixing the EVM/BTC paths is a higher-risk edit that deserves its own review.
264 lines
7.9 KiB
Go
264 lines
7.9 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
|
|
"github.com/hanzoai/orm"
|
|
|
|
"github.com/luxfi/mpc/pkg/address"
|
|
"github.com/luxfi/mpc/pkg/db"
|
|
)
|
|
|
|
// deriveFromPubKey turns a stored hex public key into an address using one of
|
|
// pkg/address's curve-gated derivations, reporting false when there is no key,
|
|
// the hex is unusable, or the key is not on the curve that chain needs.
|
|
//
|
|
// Deriving on read rather than storing a column per chain is deliberate: the
|
|
// public keys are already persisted, the derivation is pure, and every wallet
|
|
// minted before a chain was supported therefore gets its address with no
|
|
// backfill and no schema change. There is still exactly one derivation per
|
|
// chain — this only decides when to call it.
|
|
func deriveFromPubKey(pubKeyHex *string, derive func([]byte) (string, error)) (string, bool) {
|
|
if pubKeyHex == nil || *pubKeyHex == "" {
|
|
return "", false
|
|
}
|
|
raw, err := hex.DecodeString(*pubKeyHex)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
addr, err := derive(raw)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
return addr, true
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// Every value here is derived from a public key by pkg/address, never
|
|
// copied from another chain's address. Sharing a key does not mean sharing
|
|
// an encoding: "xrp" served the 20-byte EVM address for a while, and "ton"
|
|
// served the Solana base58 string, and neither is an address on the chain
|
|
// it was labelled with. A chain whose derivation fails is simply absent.
|
|
addresses := map[string]interface{}{}
|
|
if wallet.EVMAddress != nil {
|
|
// "ethereum" and "lux" are display labels for one value: the 20-byte
|
|
// EVM-runtime account address every EVM-compatible chain consumes.
|
|
addresses["ethereum"] = *wallet.EVMAddress
|
|
addresses["lux"] = *wallet.EVMAddress
|
|
}
|
|
if wallet.BtcAddress != nil {
|
|
addresses["bitcoin"] = *wallet.BtcAddress
|
|
}
|
|
if wallet.SolAddress != nil {
|
|
addresses["solana"] = *wallet.SolAddress
|
|
}
|
|
if addr, ok := deriveFromPubKey(wallet.ECDSAPubkey, address.XRP); ok {
|
|
addresses["xrp"] = addr
|
|
}
|
|
if addr, ok := deriveFromPubKey(wallet.EDDSAPubkey, address.TON); ok {
|
|
addresses["ton"] = addr
|
|
}
|
|
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)
|
|
}
|