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.
214 lines
6.1 KiB
Go
214 lines
6.1 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"
|
|
)
|
|
|
|
// 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 /api/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
|
|
result, err := s.mpc.TriggerSign(orgID, mpcWalletID, 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 /api/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
|
|
}
|