mirror of
https://github.com/luxfi/mpc.git
synced 2026-09-24 00:26:24 +00:00
* build(mpc): GOEXPERIMENT=jsonv2 alongside runtimesecret per SCALE_STANDARD
Per SCALE_STANDARD.md §2 (~/work/hanzo/hips/docs/SCALE_STANDARD.md) —
every Go production Dockerfile that emits JSON to a client builds with
GOEXPERIMENT=jsonv2. Verified on hanzoai/zip's json_bench_test.go:
- Edge POST roundtrip: -12% time, -23% allocs
- Marshal-only: -22% time
- Unmarshal-only: -19% time, -25% allocs
GOEXPERIMENT is comma-separated, so jsonv2 composes cleanly with the
pre-existing runtimesecret experiment used by the MPC data plane.
* ci: switch workflows to lux-build (luxfi-scoped ARC pool)
Cross-org runs-on dispatch failure: hanzo-build-linux-amd64 scale set is
registered to github.com/hanzoai and silently rejects luxfi/* jobs.
Switch to lux-build (githubConfigUrl=github.com/luxfi, max 20).
* test/zapauth: deterministic invalid-signature test (was flaky)
TestZapAuthInvalidSignature flipped one base64 char of the signature
segment. ECDSA accepts a wrong sig with probability ≈ 1/2^6 per
single-char flip (alphabet is 64), making the test ~1.5% flake rate
in CI.
Fix: replace the entire signature segment with all-A's. Still
decodable as base64url but cryptographically guaranteed-invalid
bytes — Verify rejection is now deterministic.
Verified: ran -count=10 ./pkg/zapauth/ — all pass.
* deps: bump accel/crypto/corona/threshold/fhe/lens/math to current tags
* api/db: decomplect — KeccakAddress on Wallet / TreasuryWallet / KeygenResult
Wave 5 of the workspace-wide eth* naming purge.
pkg/db/models.go — Wallet + TreasuryWallet:
- New canonical KeccakAddress *string field with json `keccakAddress`
- EthAddress kept with json `ethAddress` as `// Deprecated:` alias
- NormalizeAddresses() method on both structs mirrors the two fields
via the package-private mirrorAddrPtrPair helper
pkg/api/server.go — KeygenResult (backend response):
- New canonical KeccakAddress string field with json `keccak_address`
- EthAddress kept with json `eth_address` as `// Deprecated:` alias
- NormalizeAddresses() method mirrors both fields
pkg/api/handlers_wallets.go, handlers_treasury.go, handlers_bridge.go,
handlers_mpc.go — all sites that constructed wallet rows from MPC
backend results now:
1. Call result.NormalizeAddresses() on the backend response so both
KeccakAddress / EthAddress carry the same value before copy
2. Copy both fields onto the persisted Wallet / TreasuryWallet row
3. Call wal.NormalizeAddresses() to ensure DB row has both
4. Read from the canonical KeccakAddress at response-construction
sites (toWalletResponse, treasury response, bridge response)
The "ethereum"/"lux"/"xrp" labels in addresses map (handleGetWalletAddresses)
are per-chain DISPLAY labels keyed by chain name a client knows;
the values are the Keccak-derived 20-byte address. Map keys NOT
decomplected here — they're per-chain UI labels, not internal naming.
Effect on the wire:
- API consumers reading wallet.ethAddress see the same value as
wallet.keccakAddress
- New consumers can switch to keccakAddress today
- ORM persistence stores both fields (eventually consistent via
NormalizeAddresses calls at handler boundaries)
Tests: pkg/api unit tests pass (1.8s); pkg/db has no test files.
Deprecation alias retention means downstream KMS / dashboards
keep working at every step of the migration.
Per CLAUDE.md x.x.x+1.
* strip KeccakAddress + EthAddress dual-field bridge (no aliases)
Forward-only completion of the workspace strip-aliases cascade. The
canonical JSON field is evmAddress, the Go field is EVMAddress. No
NormalizeAddresses helpers, no mirrorAddrPtrPair, no deprecated
KeccakAddress, no deprecated EthAddress.
Touched:
pkg/api/server.go KeygenResult drops KeccakAddress +
EthAddress + NormalizeAddresses
pkg/db/models.go Wallet drops KeccakAddress + EthAddress
TreasuryWallet drops KeccakAddress +
EthAddress
Drops NormalizeAddresses + mirrorAddrPtrPair
pkg/api/handlers_wallets.go rip mirror calls; only EVMAddress
populated on Wallet rows
pkg/api/handlers_treasury.go rip mirror calls on TreasuryWallet
pkg/api/handlers_mpc.go rip NormalizeAddresses calls; rename
filter("ethAddress=") → ("evmAddress=")
pkg/api/handlers_bridge.go local var keccakAddress → evmAddr
pkg/api/handlers_smart_wallets.go wallet.EthAddress → wallet.EVMAddress
pkg/api/treasury_test.go mock KeygenResult EthAddress → EVMAddress
pkg/api/zap_kms_server_test.go same
cmd/mpcd/main.go pubKeyToEthAddress → pubKeyToEVMAddress,
\"eth_address\" → \"evm_address\",
local var ethAddr → evmAddr, KeygenResult
EthAddress → EVMAddress field
Deps: bump luxfi/metric v1.5.1 → v1.5.7 to pick up ExponentialBuckets
(via transitive luxfi/threshold dep).
The value IS \"EVM-runtime account address\" — that's the data model.
The Keccak256(secp256k1 pubkey) derivation is HOW.
232 lines
6.7 KiB
Go
232 lines
6.7 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
|
|
addresses["ton"] = *wallet.SolAddress // Ed25519-based
|
|
}
|
|
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)
|
|
}
|