Files
mpc/pkg/api/handlers_smart_wallets.go
zeekayandGitHub a23b56a1d2 build(mpc): GOEXPERIMENT=jsonv2 alongside runtimesecret per SCALE_STANDARD (#13)
* 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.
2026-05-29 21:05:40 -07:00

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)
}