Files
kms/pkg/mpc/wire_contract_test.go
zeekayandHanzo Dev f12d4bc52e fix(mpc): realign KMS↔MPC ZAP signing wire to mpcd contract; kill false-green
The KMS ZapClient and the mpcd KMS-facing ZAP server (luxfi/mpc
pkg/api/zap_kms_server.go) had drifted out of lockstep despite the
"MUST stay in lockstep" comment — each was only unit-tested against its
own stub, never against the other's real bytes. Three drifts:

1. Sign request: KMS sent {key_type, wallet_id, message}; mpcd requires
   {vault_id, wallet_id, payload} and hard-fails "vault_id and wallet_id
   required". SignRequest now carries VaultID (Manager supplies it from
   MPC_VAULT_ID) and Payload (json "payload").
2. Keygen response: mpcd returns snake_case (wallet_id/ecdsa_pub_key/
   evm_address); KMS KeygenResult was camelCase → decoded to an empty
   struct. Tags realigned to snake_case (the ZAP path is the only live
   decode; the REST Client is unused).
3. False-green: ZapClient.call() json-decoded the daemon's {"error":...}
   frame into a zero-value SignResult and returned it with nil error —
   the KMS looked MPC-backed but returned an empty signature. call() now
   surfaces a non-empty top-level "error" as a real error (fail closed).

Adds pkg/mpc/wire_contract_test.go — a cross-repo contract test that
transcribes the mpcd request/response shapes and fails CI if the KMS
side drifts again (the guard that was missing). Existing tests updated
to the corrected snake_case wire.

This is a library fix in luxfi/kms — it fixes the wire for all three
orgs (Lux/Zoo/Hanzo) once. ed25519/Corona keygen over this wire remains
a follow-up (mpcd's KMS-ZAP keygen carries no key_type/protocol).

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-14 14:15:05 -07:00

112 lines
4.1 KiB
Go

package mpc
// wire_contract_test.go — pins the KMS↔MPC ZAP wire to the mpcd server
// contract so the two hand-maintained copies of the wire can't silently drift
// again. The shapes below are transcribed from luxfi/mpc
// pkg/api/zap_kms_server.go (kmsZapSignRequest, kmsZapKeygenRequest) and
// pkg/api/server.go (KeygenResult) as of mpc v1.17.9. If the KMS-side structs
// drift from these, these tests fail in CI — which is the guard that was
// missing when Sign silently sent {key_type,wallet_id,message} while the
// daemon required {vault_id,wallet_id,payload}.
import (
"encoding/json"
"testing"
)
// mpcdSignRequest mirrors luxfi/mpc pkg/api/zap_kms_server.go kmsZapSignRequest.
// The daemon rejects the request unless vault_id AND wallet_id are non-empty
// and reads the message bytes from `payload`.
type mpcdSignRequest struct {
VaultID string `json:"vault_id"`
WalletID string `json:"wallet_id"`
Payload []byte `json:"payload"`
}
// mpcdKeygenResult mirrors luxfi/mpc pkg/api/server.go KeygenResult (the body
// the daemon marshals back for OpKMSKeygen).
type mpcdKeygenResult struct {
WalletID string `json:"wallet_id"`
ECDSAPubKey string `json:"ecdsa_pub_key"`
EDDSAPubKey string `json:"eddsa_pub_key"`
EVMAddress string `json:"evm_address,omitempty"`
}
func TestSignRequest_SatisfiesMPCDContract(t *testing.T) {
// A KMS SignRequest, marshaled exactly as ZapClient.Sign sends it, must
// deserialize into the daemon's request shape with all required fields set.
req := SignRequest{
VaultID: "zoo",
WalletID: "zoo-treasury-v1",
KeyType: "secp256k1",
Payload: []byte("message-to-sign"),
}
wire, err := json.Marshal(req)
if err != nil {
t.Fatalf("marshal SignRequest: %v", err)
}
var got mpcdSignRequest
if err := json.Unmarshal(wire, &got); err != nil {
t.Fatalf("daemon cannot decode SignRequest: %v", err)
}
if got.VaultID == "" {
t.Fatalf("vault_id empty on the wire — daemon rejects with %q", "vault_id and wallet_id required")
}
if got.WalletID == "" {
t.Fatalf("wallet_id empty on the wire — daemon rejects")
}
if len(got.Payload) == 0 {
t.Fatalf("payload empty on the wire — daemon rejects with %q (regression: message vs payload)", "payload required")
}
if string(got.Payload) != "message-to-sign" {
t.Fatalf("payload round-trip mismatch: %q", string(got.Payload))
}
}
func TestKeygenResult_DecodesMPCDResponse(t *testing.T) {
// The daemon's snake_case keygen response must populate the KMS struct.
// camelCase tags (the drift) silently produced an all-empty result.
daemon := mpcdKeygenResult{
WalletID: "zoo-treasury-v1",
ECDSAPubKey: "502a9a8e4b5a4869f5a290bd087fd3b87ae3866948dc53ba7fe224d940c96776",
EDDSAPubKey: "",
EVMAddress: "0x8756621734fe274fdc426381c9a4f9dec8656243",
}
wire, err := json.Marshal(daemon)
if err != nil {
t.Fatalf("marshal daemon result: %v", err)
}
var got KeygenResult
if err := json.Unmarshal(wire, &got); err != nil {
t.Fatalf("KMS cannot decode daemon keygen response: %v", err)
}
if got.WalletID != daemon.WalletID {
t.Fatalf("wallet_id not decoded (camelCase drift?): got %q", got.WalletID)
}
if got.ECDSAPubkey == nil || *got.ECDSAPubkey != daemon.ECDSAPubKey {
t.Fatalf("ecdsa_pub_key not decoded (camelCase drift?): got %v", got.ECDSAPubkey)
}
if got.EVMAddress == nil || *got.EVMAddress != daemon.EVMAddress {
t.Fatalf("evm_address not decoded: got %v", got.EVMAddress)
}
}
func TestZapErrorString_SurfacesDaemonError(t *testing.T) {
// The daemon's error frame must become a real error, never a silent empty
// result. This is the structural guard against the false-green.
if msg := zapErrorString([]byte(`{"error":"vault_id and wallet_id required"}`)); msg != "vault_id and wallet_id required" {
t.Fatalf("daemon error not surfaced: got %q", msg)
}
// A valid SignResult carries no "error" field → must NOT be flagged.
okBody, _ := json.Marshal(SignResult{R: "ab", S: "cd", Signature: "ef"})
if msg := zapErrorString(okBody); msg != "" {
t.Fatalf("valid SignResult misflagged as error: %q", msg)
}
// Empty object is not an error.
if msg := zapErrorString([]byte(`{}`)); msg != "" {
t.Fatalf("empty object misflagged as error: %q", msg)
}
}