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.
348 lines
9.6 KiB
Go
348 lines
9.6 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
|
|
"github.com/hanzoai/orm"
|
|
|
|
"github.com/luxfi/mpc/pkg/db"
|
|
"github.com/luxfi/mpc/pkg/smart"
|
|
)
|
|
|
|
func (s *Server) handleDeploySmartWallet(w http.ResponseWriter, r *http.Request) {
|
|
orgID := getOrgID(r.Context())
|
|
walletID := urlParam(r, "id")
|
|
|
|
var req struct {
|
|
Chain string `json:"chain"`
|
|
WalletType string `json:"wallet_type"` // safe, erc4337
|
|
FactoryAddress *string `json:"factory_address,omitempty"`
|
|
EntrypointAddress *string `json:"entrypoint_address,omitempty"`
|
|
Salt *string `json:"salt,omitempty"`
|
|
Owners []string `json:"owners"`
|
|
Threshold int `json:"threshold"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if req.Chain == "" || req.WalletType == "" || len(req.Owners) == 0 || req.Threshold == 0 {
|
|
writeError(w, http.StatusBadRequest, "chain, wallet_type, owners, and threshold are required")
|
|
return
|
|
}
|
|
|
|
// Verify wallet belongs to org and get the MPC EOA address
|
|
wallet, err := orm.Get[db.Wallet](s.db.ORM, walletID)
|
|
if err != nil || wallet.OrgID != orgID {
|
|
writeError(w, http.StatusNotFound, "wallet not found")
|
|
return
|
|
}
|
|
mpcEOA := ""
|
|
if wallet.EVMAddress != nil {
|
|
mpcEOA = *wallet.EVMAddress
|
|
}
|
|
if mpcEOA == "" {
|
|
writeError(w, http.StatusBadRequest, "wallet has no Ethereum address (keygen not complete)")
|
|
return
|
|
}
|
|
|
|
// Ensure the MPC EOA is always an owner
|
|
hasEOA := false
|
|
for _, o := range req.Owners {
|
|
if o == mpcEOA {
|
|
hasEOA = true
|
|
break
|
|
}
|
|
}
|
|
if !hasEOA {
|
|
req.Owners = append(req.Owners, mpcEOA)
|
|
}
|
|
|
|
salt := ""
|
|
if req.Salt != nil {
|
|
salt = *req.Salt
|
|
}
|
|
factoryAddr := ""
|
|
if req.FactoryAddress != nil {
|
|
factoryAddr = *req.FactoryAddress
|
|
}
|
|
entrypointAddr := ""
|
|
if req.EntrypointAddress != nil {
|
|
entrypointAddr = *req.EntrypointAddress
|
|
}
|
|
|
|
// Compute the predicted contract address using real ABI encoding + CREATE2
|
|
var contractAddress string
|
|
var deployCalldata []byte
|
|
switch req.WalletType {
|
|
case "safe":
|
|
cfg := smart.SafeConfig{
|
|
FactoryAddress: factoryAddr,
|
|
SingletonAddr: "",
|
|
Owners: req.Owners,
|
|
Threshold: req.Threshold,
|
|
Salt: salt,
|
|
FallbackHandler: "",
|
|
}
|
|
contractAddress = smart.PredictAddress(cfg)
|
|
deployCalldata, err = smart.EncodeDeploy(cfg)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "failed to encode Safe deploy: "+err.Error())
|
|
return
|
|
}
|
|
case "erc4337":
|
|
cfg := smart.AccountConfig{
|
|
FactoryAddress: factoryAddr,
|
|
EntrypointAddress: entrypointAddr,
|
|
OwnerAddress: mpcEOA,
|
|
Salt: salt,
|
|
}
|
|
contractAddress = smart.PredictAccountAddress(cfg)
|
|
deployCalldata, err = smart.EncodeInitCode(cfg)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "failed to encode ERC-4337 init code: "+err.Error())
|
|
return
|
|
}
|
|
default:
|
|
writeError(w, http.StatusBadRequest, fmt.Sprintf("unsupported wallet_type: %q (use safe or erc4337)", req.WalletType))
|
|
return
|
|
}
|
|
|
|
// Store deploy calldata so it can be broadcast to the chain
|
|
_ = deployCalldata
|
|
|
|
sw := orm.New[db.SmartWallet](s.db.ORM)
|
|
sw.WalletID = walletID
|
|
sw.OrgID = orgID
|
|
sw.Chain = req.Chain
|
|
sw.ContractAddress = contractAddress
|
|
sw.WalletType = req.WalletType
|
|
sw.FactoryAddress = req.FactoryAddress
|
|
sw.EntrypointAddress = req.EntrypointAddress
|
|
sw.Salt = req.Salt
|
|
sw.Owners = req.Owners
|
|
sw.Threshold = req.Threshold
|
|
sw.Status = "pending"
|
|
if err := sw.Create(); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to create smart wallet: "+err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, sw)
|
|
}
|
|
|
|
func (s *Server) handleListSmartWallets(w http.ResponseWriter, r *http.Request) {
|
|
orgID := getOrgID(r.Context())
|
|
walletID := urlParam(r, "id")
|
|
|
|
wallets, err := orm.TypedQuery[db.SmartWallet](s.db.ORM).
|
|
Filter("walletId=", walletID).
|
|
Filter("orgId=", orgID).
|
|
Order("-createdAt").
|
|
GetAll(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "database error")
|
|
return
|
|
}
|
|
if wallets == nil {
|
|
wallets = []*db.SmartWallet{}
|
|
}
|
|
writeJSON(w, http.StatusOK, wallets)
|
|
}
|
|
|
|
func (s *Server) handleGetSmartWallet(w http.ResponseWriter, r *http.Request) {
|
|
orgID := getOrgID(r.Context())
|
|
swID := urlParam(r, "id")
|
|
|
|
sw, err := orm.Get[db.SmartWallet](s.db.ORM, swID)
|
|
if err != nil || sw.OrgID != orgID {
|
|
writeError(w, http.StatusNotFound, "smart wallet not found")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, sw)
|
|
}
|
|
|
|
func (s *Server) handleProposeSafeTx(w http.ResponseWriter, r *http.Request) {
|
|
orgID := getOrgID(r.Context())
|
|
userID := getUserID(r.Context())
|
|
swID := urlParam(r, "id")
|
|
|
|
var req struct {
|
|
To string `json:"to"`
|
|
Value string `json:"value"`
|
|
Data string `json:"data,omitempty"`
|
|
Operation int `json:"operation"` // 0=Call, 1=DelegateCall
|
|
ChainID int64 `json:"chain_id"` // EVM chain ID for EIP-712
|
|
Nonce int `json:"nonce"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if req.To == "" {
|
|
writeError(w, http.StatusBadRequest, "to address is required")
|
|
return
|
|
}
|
|
if req.ChainID == 0 {
|
|
writeError(w, http.StatusBadRequest, "chain_id is required for EIP-712 Safe tx hash")
|
|
return
|
|
}
|
|
|
|
sw, err := orm.Get[db.SmartWallet](s.db.ORM, swID)
|
|
if err != nil || sw.OrgID != orgID {
|
|
writeError(w, http.StatusNotFound, "smart wallet not found")
|
|
return
|
|
}
|
|
if sw.WalletType != "safe" {
|
|
writeError(w, http.StatusBadRequest, "propose is only for Safe wallets")
|
|
return
|
|
}
|
|
|
|
// Compute real EIP-712 Safe transaction hash
|
|
safeTx := smart.SafeTransaction{
|
|
To: req.To,
|
|
Value: req.Value,
|
|
Data: req.Data,
|
|
Operation: req.Operation,
|
|
Nonce: req.Nonce,
|
|
}
|
|
txHash, err := smart.HashSafeTransaction(sw.ContractAddress, req.ChainID, safeTx)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "failed to hash safe tx: "+err.Error())
|
|
return
|
|
}
|
|
|
|
// MPC signs the EIP-712 hash via the wallet that owns this Safe
|
|
mpcWallet, err := orm.Get[db.Wallet](s.db.ORM, sw.WalletID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to load MPC wallet")
|
|
return
|
|
}
|
|
signResult, err := s.mpc.TriggerSign(orgID, mpcWallet.WalletID, txHash)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "MPC signing failed: "+err.Error())
|
|
return
|
|
}
|
|
|
|
walletID := sw.WalletID
|
|
tx := orm.New[db.Transaction](s.db.ORM)
|
|
tx.OrgID = orgID
|
|
tx.WalletID = &walletID
|
|
tx.TxType = "safe_proposal"
|
|
tx.Chain = sw.Chain
|
|
tx.ToAddress = nilIfEmpty(req.To)
|
|
tx.Amount = nilIfEmpty(req.Value)
|
|
tx.RawTx = []byte(req.Data)
|
|
tx.SignatureR = nilIfEmpty(signResult.R)
|
|
tx.SignatureS = nilIfEmpty(signResult.S)
|
|
tx.TxHash = nilIfEmpty(fmt.Sprintf("0x%x", txHash))
|
|
tx.Status = "signed"
|
|
tx.InitiatedBy = nilIfEmpty(userID)
|
|
if err := tx.Create(); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to create transaction: "+err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, map[string]any{
|
|
"transaction": tx,
|
|
"safe_tx_hash": fmt.Sprintf("0x%x", txHash),
|
|
"signature_r": signResult.R,
|
|
"signature_s": signResult.S,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleExecuteSafeTx(w http.ResponseWriter, r *http.Request) {
|
|
orgID := getOrgID(r.Context())
|
|
swID := urlParam(r, "id")
|
|
|
|
var req struct {
|
|
SafeTxHash string `json:"safe_tx_hash"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if req.SafeTxHash == "" {
|
|
writeError(w, http.StatusBadRequest, "safe_tx_hash is required")
|
|
return
|
|
}
|
|
|
|
sw, err := orm.Get[db.SmartWallet](s.db.ORM, swID)
|
|
if err != nil || sw.OrgID != orgID {
|
|
writeError(w, http.StatusNotFound, "smart wallet not found")
|
|
return
|
|
}
|
|
|
|
// Find the most recent pending safe_proposal for this wallet
|
|
txList, err := orm.TypedQuery[db.Transaction](s.db.ORM).
|
|
Filter("walletId=", sw.WalletID).
|
|
Filter("orgId=", orgID).
|
|
Filter("txType=", "safe_proposal").
|
|
Filter("status=", "pending").
|
|
Order("-createdAt").
|
|
Limit(1).
|
|
GetAll(r.Context())
|
|
if err != nil || len(txList) == 0 {
|
|
writeError(w, http.StatusNotFound, "no pending safe proposal found for this wallet")
|
|
return
|
|
}
|
|
|
|
tx := txList[0]
|
|
tx.Status = "executing"
|
|
tx.TxHash = nilIfEmpty(req.SafeTxHash)
|
|
if err := tx.Update(); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to update transaction")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, tx)
|
|
}
|
|
|
|
func (s *Server) handleUserOperation(w http.ResponseWriter, r *http.Request) {
|
|
orgID := getOrgID(r.Context())
|
|
userID := getUserID(r.Context())
|
|
swID := urlParam(r, "id")
|
|
|
|
var req struct {
|
|
CallData string `json:"call_data"`
|
|
Value string `json:"value,omitempty"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if req.CallData == "" {
|
|
writeError(w, http.StatusBadRequest, "call_data is required")
|
|
return
|
|
}
|
|
|
|
sw, err := orm.Get[db.SmartWallet](s.db.ORM, swID)
|
|
if err != nil || sw.OrgID != orgID {
|
|
writeError(w, http.StatusNotFound, "smart wallet not found")
|
|
return
|
|
}
|
|
if sw.WalletType != "erc4337" {
|
|
writeError(w, http.StatusBadRequest, "user operations only supported for ERC-4337 wallets")
|
|
return
|
|
}
|
|
|
|
walletID := sw.WalletID
|
|
tx := orm.New[db.Transaction](s.db.ORM)
|
|
tx.OrgID = orgID
|
|
tx.WalletID = &walletID
|
|
tx.TxType = "user_operation"
|
|
tx.Chain = sw.Chain
|
|
tx.Amount = nilIfEmpty(req.Value)
|
|
tx.RawTx = []byte(req.CallData)
|
|
tx.Status = "submitted"
|
|
tx.InitiatedBy = nilIfEmpty(userID)
|
|
if err := tx.Create(); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to create user operation: "+err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, tx)
|
|
}
|