Files
zeekay c9c131ec85 bridge: an unknown network is unknown, not Ethereum
AddressTypeFor returned AddressTypeETH for any network it did not name,
on the reasoning in its own comment: "safe because every
BridgeVM-supported chain is at minimum an EVM". That stopped being true
at Bitcoin. An unlisted non-EVM network silently became an EVM one, so
custody minted an 0x address for a chain that has never seen one, the
watcher looked for an EVM deposit that could not exist, and the
broadcaster reached for the EVM arm.

It returns (type, known) now, and the compiler walked every caller:
keygen refuses to mint for an unknown family, the deposit checker
refuses to look, the broadcaster refuses to send, pool routing returns
no pool, and the withdrawal gate HOLDS an unknown family rather than
waving it through — a family nobody has named is the one to hold.

A test asserted the old behaviour by name —
TestKeygen_UnknownNetwork_DefaultsToETH. It encoded the bug as the
specification, which is how a bug survives a test suite.

Cardano was mapped to AddressTypeSOL here, the same defect fixed in
mpc-wallet.ts earlier today. Same fact, two places, one fixed. It has
its own AddressTypeADA now.

AND THE ONE I CAUSED. Adding nine networks earlier, I filled the network
rows and the asset rows and missed internal/depositcheck's RPC table —
seven of eight EVM additions had no deposit RPC, so a Celo deposit would
have been watched for on a chain the watcher could not reach. That is
precisely the failure the five-table shape produces, committed by
someone who had just written a test against it.

Fixed, and made structural:
TestEveryOfferedNetworkIsCompleteAcrossTheTables walks the config and
requires an address family, a deposit RPC and an asset row for every
network offered. Verified by mutation — dropping Celo's RPC names it.

That test refuses Cardano, correctly. Its address encoder is right and
internal/mchain names the family, but depositcheck has no Cardano
checker, so a deposit to a correct address is observed by nobody and the
swap waits forever on money that already arrived. Held back with the
reason in the config rather than shipped and discovered.
2026-08-29 13:27:15 -07:00

933 lines
29 KiB
Go

// Tests for the m-chain MPC keygen client. Drives the client against
// an httptest.Server mock that returns canonical MPCKeygenResult
// shapes; covers each AddressType branch + error paths.
package mchain
import (
"context"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// mockCluster mints a fake MPC cluster: returns the keygenResult
// supplied in `result`, or surfaces `status` + `errorBody` for the
// HTTP-failure paths.
type mockCluster struct {
t *testing.T
server *httptest.Server
result *keygenResult
status int
errorBody string
lastBody []byte
}
func newMockCluster(t *testing.T) *mockCluster {
t.Helper()
m := &mockCluster{t: t, status: 200}
m.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if r.URL.Path != "/keygen" {
http.Error(w, "not found", http.StatusNotFound)
return
}
m.lastBody, _ = io.ReadAll(r.Body)
w.Header().Set("Content-Type", "application/json")
if m.status != 200 {
w.WriteHeader(m.status)
_, _ = w.Write([]byte(m.errorBody))
return
}
if m.result != nil {
_ = json.NewEncoder(w).Encode(m.result)
return
}
_, _ = w.Write([]byte("{}"))
}))
t.Cleanup(m.server.Close)
return m
}
// clientFor builds a Client pointed at the mock with a tight timeout
// and a default org id so happy-path tests don't need to set one.
func clientFor(m *mockCluster, opts ...func(*Client)) *Client {
c := &Client{
APIURL: m.server.URL,
OrgID: "test-org",
Timeout: 2 * time.Second,
}
for _, opt := range opts {
opt(c)
}
return c
}
// =============================================================================
// AddressTypeFor — registry lookups
// =============================================================================
// An unknown network reports unknown, not eth. The table used to return eth
// for anything it did not name — safe only while every chain was an EVM, which
// stopped being true at Bitcoin.
func TestAddressTypeFor_UnknownIsUnknown(t *testing.T) {
for _, net := range []string{"UNKNOWN_NETWORK", "MARS_MAINNET", ""} {
if got, known := AddressTypeFor(net); known {
t.Errorf("AddressTypeFor(%q) = %q, known — an unnamed network has no known address format", net, got)
}
}
}
func TestAddressTypeFor_Coverage(t *testing.T) {
cases := []struct {
net string
want AddressType
}{
{"ETHEREUM_SEPOLIA", AddressTypeETH},
{"LUX_TESTNET", AddressTypeETH},
{"BSC_MAINNET", AddressTypeETH},
{"BITCOIN_TESTNET", AddressTypeBTC},
{"SOLANA_DEVNET", AddressTypeSOL},
{"TON_MAINNET", AddressTypeTON},
{"XRP_TESTNET", AddressTypeXRP},
{"POLKADOT_MAINNET", AddressTypeDOT},
{"POLKADOT_TESTNET", AddressTypeDOT},
{"KUSAMA_MAINNET", AddressTypeDOT},
{"CARDANO_MAINNET", AddressTypeADA}, // placeholder slot
// default fallback
}
for _, tc := range cases {
t.Run(tc.net, func(t *testing.T) {
if got, _ := AddressTypeFor(tc.net); got != tc.want {
t.Errorf("AddressTypeFor(%q) = %q, want %q", tc.net, got, tc.want)
}
})
}
}
// =============================================================================
// KeygenForDeposit — happy paths per AddressType
// =============================================================================
func TestKeygen_EVM_PicksETHAddress(t *testing.T) {
m := newMockCluster(t)
m.result = &keygenResult{
WalletID: "bridge-ethereum_sepolia-1718000000",
ETHAddress: "0xa28fAE14eB42e7A5C36Ad2D774a2b7Eb293c4473",
BTCAddress: "should not be picked",
SOLAddress: "should not be picked",
ResultType: "success",
ECDSAPubKey: "0xpub",
}
c := clientFor(m)
w, err := c.KeygenForDeposit(context.Background(), "ETHEREUM_SEPOLIA")
if err != nil {
t.Fatalf("KeygenForDeposit err: %v", err)
}
if w.Address != "0xa28fAE14eB42e7A5C36Ad2D774a2b7Eb293c4473" {
t.Errorf("Address = %q", w.Address)
}
if w.AddressType != AddressTypeETH {
t.Errorf("AddressType = %q", w.AddressType)
}
if !strings.HasPrefix(w.Name, "bridge-") {
t.Errorf("Name should start with bridge-, got %q", w.Name)
}
// Verify the wallet_id we sent matches the lowercase-network pattern.
var req map[string]string
if err := json.Unmarshal(m.lastBody, &req); err != nil {
t.Fatalf("decode last body: %v", err)
}
if got := req["wallet_id"]; !strings.HasPrefix(got, "bridge-ethereum_sepolia-") {
t.Errorf("wallet_id = %q, want prefix bridge-ethereum_sepolia-", got)
}
}
func TestKeygen_BTC_PicksBTCAddress(t *testing.T) {
m := newMockCluster(t)
m.result = &keygenResult{
WalletID: "wid",
ETHAddress: "0xeth",
BTCAddress: "tb1qbtcaddress",
SOLAddress: "should not be picked",
ResultType: "success",
}
c := clientFor(m)
w, err := c.KeygenForDeposit(context.Background(), "BITCOIN_TESTNET")
if err != nil {
t.Fatalf("err: %v", err)
}
if w.Address != "tb1qbtcaddress" {
t.Errorf("Address = %q, want BTC address", w.Address)
}
if w.AddressType != AddressTypeBTC {
t.Errorf("AddressType = %q", w.AddressType)
}
}
func TestKeygen_SOL_PicksSOLAddress(t *testing.T) {
m := newMockCluster(t)
m.result = &keygenResult{
WalletID: "wid",
ETHAddress: "0xeth",
SOLAddress: "SoLaNaAdDrEsS",
ResultType: "success",
}
c := clientFor(m)
w, err := c.KeygenForDeposit(context.Background(), "SOLANA_DEVNET")
if err != nil {
t.Fatalf("err: %v", err)
}
if w.Address != "SoLaNaAdDrEsS" {
t.Errorf("Address = %q", w.Address)
}
}
func TestKeygen_SOL_SendsEd25519Curve(t *testing.T) {
// Solana keygen MUST request the Ed25519 curve so the cluster
// routes to its FROST stack and returns an eddsa_pub_key /
// sol_address slot. Without this, the cluster defaults to
// secp256k1 and the resulting "SOL address" is just the ETH
// pubkey base58'd — broadcast-time signatures would not verify.
m := newMockCluster(t)
m.result = &keygenResult{SOLAddress: "SoLaNa", ResultType: "success"}
c := clientFor(m)
if _, err := c.KeygenForDeposit(context.Background(), "SOLANA_DEVNET"); err != nil {
t.Fatalf("err: %v", err)
}
var body map[string]string
if err := json.Unmarshal(m.lastBody, &body); err != nil {
t.Fatalf("decode body: %v", err)
}
if body["curve"] != "ed25519" {
t.Errorf("curve = %q, want \"ed25519\" for SOL keygen (body=%s)", body["curve"], m.lastBody)
}
}
func TestKeygen_ETH_SendsSecp256k1Curve(t *testing.T) {
// Regression: ETH keygen MUST stay on secp256k1 even after the
// Ed25519 hint plumbing. The cluster's default behaviour matches
// secp256k1; we still send the field explicitly to make the wire
// shape symmetric.
m := newMockCluster(t)
m.result = &keygenResult{ETHAddress: "0xfoo", ResultType: "success"}
c := clientFor(m)
if _, err := c.KeygenForDeposit(context.Background(), "ETHEREUM_SEPOLIA"); err != nil {
t.Fatalf("err: %v", err)
}
var body map[string]string
if err := json.Unmarshal(m.lastBody, &body); err != nil {
t.Fatalf("decode body: %v", err)
}
if body["curve"] != "secp256k1" {
t.Errorf("curve = %q, want \"secp256k1\" for ETH keygen (body=%s)", body["curve"], m.lastBody)
}
}
func TestCurveFor(t *testing.T) {
cases := []struct {
t AddressType
want Curve
}{
{AddressTypeETH, CurveSecp256k1},
{AddressTypeBTC, CurveSecp256k1},
{AddressTypeXRP, CurveSecp256k1},
{AddressTypeSOL, CurveEd25519},
{AddressTypeTON, CurveEd25519},
{AddressTypeDOT, CurveEd25519},
}
for _, tc := range cases {
if got := CurveFor(tc.t); got != tc.want {
t.Errorf("CurveFor(%q) = %q, want %q", tc.t, got, tc.want)
}
}
}
func TestKeygen_TON_UsesSOLSlot(t *testing.T) {
// TON shares the SOL slot in the cluster's current keygen output —
// matches TS placeholder behaviour in mpc-wallet.ts. Test pins this
// so a refactor that breaks it is caught early.
m := newMockCluster(t)
m.result = &keygenResult{SOLAddress: "tonish_address"}
c := clientFor(m)
w, err := c.KeygenForDeposit(context.Background(), "TON_TESTNET")
if err != nil {
t.Fatalf("err: %v", err)
}
if w.AddressType != AddressTypeTON {
t.Errorf("AddressType = %q, want ton", w.AddressType)
}
if w.Address != "tonish_address" {
t.Errorf("Address = %q", w.Address)
}
}
func TestKeygen_XRP_FallsBackToETHSlotWhenPubKeyAbsent(t *testing.T) {
// Legacy mock cluster fixture without ECDSAPubKey populated → the
// XRP branch falls back to ETHAddress so existing shape-only tests
// keep working. Production keygen always sets ECDSAPubKey, exercised
// in TestKeygen_XRP_DerivesRAddressFromPubKey below.
m := newMockCluster(t)
m.result = &keygenResult{ETHAddress: "0xeth_for_xrp"}
c := clientFor(m)
w, err := c.KeygenForDeposit(context.Background(), "XRP_TESTNET")
if err != nil {
t.Fatalf("err: %v", err)
}
if w.AddressType != AddressTypeXRP {
t.Errorf("AddressType = %q", w.AddressType)
}
if w.Address != "0xeth_for_xrp" {
t.Errorf("Address = %q", w.Address)
}
}
func TestKeygen_XRP_DerivesRAddressFromPubKey(t *testing.T) {
// Canonical test vector: pubkey 0330... → r-address via the
// RIPEMD160(SHA256(pubkey)) + base58check pipeline in internal/xrpl.
// The derived address must round-trip parseable.
m := newMockCluster(t)
m.result = &keygenResult{
ETHAddress: "0xshouldnotbeused",
ECDSAPubKey: "0330E7FC9D56BB25D6893BA3F317AE5BCF33B3291BD63DB32654A313222F7FD020",
}
c := clientFor(m)
w, err := c.KeygenForDeposit(context.Background(), "XRP_MAINNET")
if err != nil {
t.Fatalf("err: %v", err)
}
if w.AddressType != AddressTypeXRP {
t.Errorf("AddressType = %q", w.AddressType)
}
if w.Address == "" || w.Address[0] != 'r' {
t.Errorf("Address should be an r-address; got %q", w.Address)
}
if w.Address == "0xshouldnotbeused" {
t.Error("XRP branch must derive from ECDSAPubKey, not fall back to ETH slot")
}
}
func TestKeygen_XRP_BadPubKeyHex_Errors(t *testing.T) {
m := newMockCluster(t)
m.result = &keygenResult{
ECDSAPubKey: "not-hex",
}
c := clientFor(m)
_, err := c.KeygenForDeposit(context.Background(), "XRP_MAINNET")
if err == nil {
t.Fatal("expected error for bad ECDSAPubKey hex")
}
}
func TestKeygen_DOT_DerivesSS58FromPubKey(t *testing.T) {
// The cluster returns a 33-byte compressed ECDSA pubkey; the bridge
// derives the SS58 address client-side.
//
// Pin a deterministic pubkey so the test asserts on a fixed SS58.
const pub = "02bf3e72a73be7a3a1b9b7872c7e3a7bf1c5e22f4e7f2a73be7a3a1b9b7872c7e3"
m := newMockCluster(t)
m.result = &keygenResult{
WalletID: "bridge-polkadot_mainnet-1",
ECDSAPubKey: pub,
ResultType: "success",
}
c := clientFor(m)
w, err := c.KeygenForDeposit(context.Background(), "POLKADOT_MAINNET")
if err != nil {
t.Fatalf("KeygenForDeposit: %v", err)
}
if w.AddressType != AddressTypeDOT {
t.Errorf("AddressType = %v", w.AddressType)
}
if !strings.HasPrefix(w.Address, "1") {
t.Errorf("Polkadot SS58 should start with '1', got %q", w.Address)
}
if hex.EncodeToString(w.ECDSAPubKey) != pub {
t.Errorf("ECDSAPubKey = %x", w.ECDSAPubKey)
}
// Second call with the same pubkey is deterministic.
w2, _ := c.KeygenForDeposit(context.Background(), "POLKADOT_MAINNET")
if w2.Address != w.Address {
t.Error("SS58 derivation should be deterministic")
}
}
func TestKeygen_DOT_MissingPubKeyErrors(t *testing.T) {
// If the cluster returns no ecdsa_pub_key, we can't derive an SS58
// and surface ErrMissingPubKey.
m := newMockCluster(t)
m.result = &keygenResult{
WalletID: "bridge-dot-1",
ResultType: "success",
// no ECDSAPubKey
}
c := clientFor(m)
_, err := c.KeygenForDeposit(context.Background(), "POLKADOT_MAINNET")
if !errors.Is(err, ErrMissingPubKey) {
t.Errorf("expected ErrMissingPubKey, got %v", err)
}
}
func TestKeygen_DOT_TestnetPrefix(t *testing.T) {
const pub = "02bf3e72a73be7a3a1b9b7872c7e3a7bf1c5e22f4e7f2a73be7a3a1b9b7872c7e3"
m := newMockCluster(t)
m.result = &keygenResult{ECDSAPubKey: pub, ResultType: "success"}
c := clientFor(m)
w, err := c.KeygenForDeposit(context.Background(), "POLKADOT_TESTNET")
if err != nil {
t.Fatal(err)
}
// Westend (generic) addresses start with '5'.
if !strings.HasPrefix(w.Address, "5") {
t.Errorf("Westend SS58 should start with '5', got %q", w.Address)
}
}
func TestKeygen_UnknownNetwork_IsRefused(t *testing.T) {
// This asserted the opposite until today: an unknown network fell back to
// an ETH address, on the reasoning that every supported chain "is at
// minimum an EVM". That stopped being true at Bitcoin. A network the
// address table does not name has an unknown address format, and minting
// an 0x address for it produces an address on the wrong chain — found
// when a user sends funds to it.
m := newMockCluster(t)
m.result = &keygenResult{ETHAddress: "0xfallback"}
c := clientFor(m)
if _, err := c.KeygenForDeposit(context.Background(), "MARS_MAINNET"); err == nil {
t.Fatal("a wallet was minted for a network with no known address format")
}
}
func TestLegacyDepositString(t *testing.T) {
w := &Wallet{Name: "bridge-lux_testnet-12345", Address: "0xdeadbeef"}
if got := w.LegacyDepositString(); got != "bridge-lux_testnet-12345###0xdeadbeef" {
t.Errorf("LegacyDepositString = %q", got)
}
var nilW *Wallet
if got := nilW.LegacyDepositString(); got != "" {
t.Errorf("nil wallet should return empty string, got %q", got)
}
}
// =============================================================================
// Wallet-id construction
// =============================================================================
func TestBuildWalletID_LowercaseAndTimestamped(t *testing.T) {
frozen := time.Date(2026, 5, 25, 0, 0, 0, 0, time.UTC)
c := &Client{APIURL: "http://nowhere", Clock: func() time.Time { return frozen }}
got := c.buildWalletID("ETHEREUM_SEPOLIA")
want := "bridge-ethereum_sepolia-" + formatMillis(frozen)
if got != want {
t.Errorf("buildWalletID = %q, want %q", got, want)
}
}
func formatMillis(t time.Time) string {
return fmt_Itoa(t.UnixMilli())
}
func fmt_Itoa(v int64) string {
// Local helper to avoid importing strconv just for this test;
// keeps the test file's imports minimal.
if v == 0 {
return "0"
}
var buf [20]byte
idx := len(buf)
neg := false
if v < 0 {
neg = true
v = -v
}
for v > 0 {
idx--
buf[idx] = byte('0' + v%10)
v /= 10
}
if neg {
idx--
buf[idx] = '-'
}
return string(buf[idx:])
}
// =============================================================================
// Error paths
// =============================================================================
func TestKeygen_NoAPIURL(t *testing.T) {
c := &Client{} // APIURL empty
_, err := c.KeygenForDeposit(context.Background(), "ETHEREUM_SEPOLIA")
if err == nil {
t.Fatal("expected error, got nil")
}
var mpcErr *MPCError
if !errors.As(err, &mpcErr) {
t.Fatalf("expected *MPCError, got %T", err)
}
if !strings.Contains(mpcErr.Message, "APIURL not configured") {
t.Errorf("expected APIURL message, got %q", mpcErr.Message)
}
}
func TestKeygen_HTTPNon200(t *testing.T) {
m := newMockCluster(t)
m.status = http.StatusInternalServerError
m.errorBody = `{"error":"cluster unhappy"}`
c := clientFor(m)
_, err := c.KeygenForDeposit(context.Background(), "ETHEREUM_SEPOLIA")
if err == nil {
t.Fatal("expected error, got nil")
}
var mpcErr *MPCError
if !errors.As(err, &mpcErr) {
t.Fatalf("expected *MPCError, got %T: %v", err, err)
}
if mpcErr.HTTPStatus != 500 {
t.Errorf("HTTPStatus = %d, want 500", mpcErr.HTTPStatus)
}
}
func TestKeygen_MalformedResponse(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte("not json at all"))
}))
t.Cleanup(srv.Close)
c := &Client{APIURL: srv.URL, OrgID: "test-org", Timeout: time.Second}
_, err := c.KeygenForDeposit(context.Background(), "ETHEREUM_SEPOLIA")
if err == nil {
t.Fatal("expected error, got nil")
}
var mpcErr *MPCError
if !errors.As(err, &mpcErr) {
t.Fatalf("expected *MPCError, got %T", err)
}
if !strings.Contains(mpcErr.Message, "decode response") {
t.Errorf("expected decode-response error, got %q", mpcErr.Message)
}
}
func TestKeygen_ResultTypeError(t *testing.T) {
m := newMockCluster(t)
m.result = &keygenResult{
ResultType: "failed",
Error: "quorum unreachable",
}
c := clientFor(m)
_, err := c.KeygenForDeposit(context.Background(), "ETHEREUM_SEPOLIA")
if err == nil {
t.Fatal("expected error, got nil")
}
var mpcErr *MPCError
if !errors.As(err, &mpcErr) {
t.Fatalf("expected *MPCError, got %T", err)
}
if !strings.Contains(mpcErr.Message, "result_type=failed") {
t.Errorf("expected result_type message, got %q", mpcErr.Message)
}
if !strings.Contains(mpcErr.Message, "quorum unreachable") {
t.Errorf("expected upstream message to surface, got %q", mpcErr.Message)
}
}
func TestKeygen_MissingAddressForType(t *testing.T) {
m := newMockCluster(t)
// ETHEREUM_SEPOLIA expects ETHAddress; deliberately omit it.
m.result = &keygenResult{
BTCAddress: "tb1q...",
SOLAddress: "SoL...",
}
c := clientFor(m)
_, err := c.KeygenForDeposit(context.Background(), "ETHEREUM_SEPOLIA")
if err == nil {
t.Fatal("expected error, got nil")
}
var mpcErr *MPCError
if !errors.As(err, &mpcErr) {
t.Fatalf("expected *MPCError, got %T", err)
}
if !strings.Contains(mpcErr.Message, "no eth address") {
t.Errorf("expected missing-address error, got %q", mpcErr.Message)
}
}
func TestKeygen_ContextCanceled(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-r.Context().Done():
case <-time.After(2 * time.Second):
}
}))
t.Cleanup(srv.Close)
c := &Client{APIURL: srv.URL, OrgID: "test-org", Timeout: 5 * time.Second}
ctx, cancel := context.WithCancel(context.Background())
cancel() // already canceled
_, err := c.KeygenForDeposit(ctx, "ETHEREUM_SEPOLIA")
if err == nil {
t.Fatal("expected cancellation, got nil")
}
if !errors.Is(err, context.Canceled) {
t.Errorf("expected context.Canceled, got %T: %v", err, err)
}
}
func TestKeygen_TimeoutFires(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-r.Context().Done():
case <-time.After(2 * time.Second):
}
}))
t.Cleanup(srv.Close)
c := &Client{APIURL: srv.URL, OrgID: "test-org", Timeout: 200 * time.Millisecond}
start := time.Now()
_, err := c.KeygenForDeposit(context.Background(), "ETHEREUM_SEPOLIA")
if err == nil || time.Since(start) > time.Second {
t.Fatalf("expected fast timeout, got err=%v dur=%v", err, time.Since(start))
}
if !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("expected context.DeadlineExceeded, got %T: %v", err, err)
}
}
// =============================================================================
// MPCError formatting
// =============================================================================
// =============================================================================
// Auth + org_id wire shape — matches live mpcd contract
// =============================================================================
func TestKeygen_SendsBearerTokenAndOrgID(t *testing.T) {
var gotAuth, gotBody string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
body, _ := io.ReadAll(r.Body)
gotBody = string(body)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"wallet_id": "x",
"eth_address": "0xok",
"result_type": "success",
})
}))
t.Cleanup(srv.Close)
c := &Client{
APIURL: srv.URL,
Token: "deadbeef",
OrgID: "bridge",
Timeout: time.Second,
}
if _, err := c.KeygenForDeposit(context.Background(), "ETHEREUM_SEPOLIA"); err != nil {
t.Fatal(err)
}
if gotAuth != "Bearer deadbeef" {
t.Errorf("Authorization = %q, want \"Bearer deadbeef\"", gotAuth)
}
if !strings.Contains(gotBody, `"org_id":"bridge"`) {
t.Errorf("body missing org_id: %s", gotBody)
}
if !strings.Contains(gotBody, `"wallet_id":"bridge-ethereum_sepolia-`) {
t.Errorf("body missing wallet_id with bridge- prefix: %s", gotBody)
}
}
func TestKeygen_OrgIDMissing_FailsFast(t *testing.T) {
// No mock server hit — must fail before transport.
c := &Client{APIURL: "http://unused", Timeout: time.Second} // OrgID empty
_, err := c.KeygenForDeposit(context.Background(), "ETHEREUM_SEPOLIA")
if err == nil {
t.Fatal("expected error, got nil")
}
var mpcErr *MPCError
if !errors.As(err, &mpcErr) || !strings.Contains(mpcErr.Message, "org_id required") {
t.Errorf("unexpected error: %v", err)
}
}
func TestKeygenForDepositWithOrg_OverridesDefault(t *testing.T) {
var gotBody string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
gotBody = string(body)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"eth_address": "0x", "result_type": "success",
})
}))
t.Cleanup(srv.Close)
c := &Client{APIURL: srv.URL, OrgID: "default-org", Timeout: time.Second}
if _, err := c.KeygenForDepositWithOrg(context.Background(), "ETHEREUM_SEPOLIA", "override-org"); err != nil {
t.Fatal(err)
}
if !strings.Contains(gotBody, `"org_id":"override-org"`) {
t.Errorf("expected per-call org override, got body=%s", gotBody)
}
}
func TestKeygen_NoToken_SkipsAuthHeader(t *testing.T) {
var gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"eth_address": "0x", "result_type": "success",
})
}))
t.Cleanup(srv.Close)
c := &Client{APIURL: srv.URL, OrgID: "x", Timeout: time.Second} // Token empty
if _, err := c.KeygenForDeposit(context.Background(), "ETHEREUM_SEPOLIA"); err != nil {
t.Fatal(err)
}
if gotAuth != "" {
t.Errorf("Authorization should be unset, got %q", gotAuth)
}
}
func TestNewAuthed_PopulatesFields(t *testing.T) {
c := NewAuthed("http://x:9800", "tok", "myorg", 5*time.Second)
if c.APIURL != "http://x:9800" || c.Token != "tok" || c.OrgID != "myorg" || c.Timeout != 5*time.Second {
t.Errorf("unexpected client: %+v", c)
}
}
// =============================================================================
// DeriveInternalKey — matches mpcd's deterministic derivation
// =============================================================================
func TestDeriveInternalKey_MatchesUpstreamFormula(t *testing.T) {
// Hand-computed reference: ed25519 private key = 64 bytes; seed =
// first 32. SHA-256(seed || "mpc-internal-api"), hex.
seed := strings.Repeat("ab", 32) // 32 bytes of 0xab
pub := strings.Repeat("cd", 32) // arbitrary pub
identityJSON := []byte(`{"private_key":"` + seed + pub + `"}`)
key, err := DeriveInternalKey(identityJSON)
if err != nil {
t.Fatalf("DeriveInternalKey: %v", err)
}
// Independently compute the expected hash.
seedBytes := make([]byte, 32)
for i := range seedBytes {
seedBytes[i] = 0xab
}
// Skip the actual hash check by length to keep this independent
// of imports; ensure deterministic non-empty hex.
if len(key) != 64 {
t.Errorf("derived key length = %d, want 64 hex chars (32 bytes)", len(key))
}
// Deterministic: calling again must produce the same key.
key2, _ := DeriveInternalKey(identityJSON)
if key != key2 {
t.Errorf("non-deterministic: %q vs %q", key, key2)
}
}
func TestDeriveInternalKey_BadJSON(t *testing.T) {
if _, err := DeriveInternalKey([]byte("not json")); err == nil {
t.Fatal("expected parse error")
}
}
func TestDeriveInternalKey_BadHex(t *testing.T) {
if _, err := DeriveInternalKey([]byte(`{"private_key":"zz"}`)); err == nil {
t.Fatal("expected hex error")
}
}
func TestDeriveInternalKey_WrongLength(t *testing.T) {
short := strings.Repeat("ab", 30) // 30 bytes — too short
if _, err := DeriveInternalKey([]byte(`{"private_key":"` + short + `"}`)); err == nil {
t.Fatal("expected length error")
}
}
// =============================================================================
// SignForWallet — threshold signing client tests
// =============================================================================
func TestSign_HappyPath(t *testing.T) {
var gotBody, gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/sign" || r.Method != http.MethodPost {
http.Error(w, "not found", http.StatusNotFound)
return
}
gotAuth = r.Header.Get("Authorization")
body, _ := io.ReadAll(r.Body)
gotBody = string(body)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"wallet_id": "bridge-test",
"signature": "0xabcdef",
"session_id": "sess_42",
"result_type": "success",
})
}))
t.Cleanup(srv.Close)
c := &Client{APIURL: srv.URL, Token: "tok", OrgID: "bridge", Timeout: time.Second}
res, err := c.SignForWallet(context.Background(), "bridge-test", "0x1234")
if err != nil {
t.Fatalf("SignForWallet: %v", err)
}
if res.Signature != "0xabcdef" || res.SessionID != "sess_42" {
t.Errorf("unexpected sig result: %+v", res)
}
if gotAuth != "Bearer tok" {
t.Errorf("Authorization = %q, want \"Bearer tok\"", gotAuth)
}
if !strings.Contains(gotBody, `"org_id":"bridge"`) ||
!strings.Contains(gotBody, `"wallet_id":"bridge-test"`) ||
!strings.Contains(gotBody, `"message":"0x1234"`) {
t.Errorf("unexpected request body: %s", gotBody)
}
}
func TestSign_MissingParamsFailFast(t *testing.T) {
cases := []struct {
name string
walletID string
msg string
orgID string
wantContain string
}{
{"no wallet", "", "0xab", "org", "wallet_id required"},
{"no message", "w", "", "org", "message required"},
{"no org", "w", "0xab", "", "org_id required"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
c := &Client{APIURL: "http://unused", OrgID: tc.orgID, Timeout: time.Second}
_, err := c.SignForWallet(context.Background(), tc.walletID, tc.msg)
if err == nil || !strings.Contains(err.Error(), tc.wantContain) {
t.Errorf("got %v, want contains %q", err, tc.wantContain)
}
})
}
}
func TestSign_ResultTypeError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"wallet_id": "w",
"result_type": "failed",
"error": "no quorum",
})
}))
t.Cleanup(srv.Close)
c := &Client{APIURL: srv.URL, OrgID: "bridge", Timeout: time.Second}
_, err := c.SignForWallet(context.Background(), "w", "0xab")
if err == nil {
t.Fatal("expected error, got nil")
}
var mpcErr *MPCError
if !errors.As(err, &mpcErr) || !strings.Contains(mpcErr.Message, "no quorum") {
t.Errorf("expected MPCError with quorum, got %v", err)
}
}
func TestSign_MissingSignatureField(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
// result_type=success but no signature → server bug; we
// surface it cleanly.
_ = json.NewEncoder(w).Encode(map[string]any{
"wallet_id": "w",
"result_type": "success",
"signature": "",
})
}))
t.Cleanup(srv.Close)
c := &Client{APIURL: srv.URL, OrgID: "bridge", Timeout: time.Second}
_, err := c.SignForWallet(context.Background(), "w", "0xab")
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "no signature returned") {
t.Errorf("got %v, want \"no signature returned\"", err)
}
}
func TestSign_HTTPNon200(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "service down", http.StatusServiceUnavailable)
}))
t.Cleanup(srv.Close)
c := &Client{APIURL: srv.URL, OrgID: "bridge", Timeout: time.Second}
_, err := c.SignForWallet(context.Background(), "w", "0xab")
var mpcErr *MPCError
if !errors.As(err, &mpcErr) || mpcErr.HTTPStatus != 503 {
t.Errorf("expected MPCError with HTTPStatus 503, got %v", err)
}
}
func TestSign_PerCallOrgOverride(t *testing.T) {
var gotBody string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
gotBody = string(body)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"wallet_id": "w", "signature": "0xs", "result_type": "success",
})
}))
t.Cleanup(srv.Close)
c := &Client{APIURL: srv.URL, OrgID: "default", Timeout: time.Second}
if _, err := c.SignForWalletWithOrg(context.Background(), "w", "0xab", "tenant-a"); err != nil {
t.Fatal(err)
}
if !strings.Contains(gotBody, `"org_id":"tenant-a"`) {
t.Errorf("expected per-call org override, got body=%s", gotBody)
}
}
func TestMPCError_String(t *testing.T) {
cases := []struct {
name string
err *MPCError
want string
}{
{"with HTTP status", &MPCError{Op: "keygen", HTTPStatus: 502, Message: "bad gateway"},
"mchain: keygen HTTP 502: bad gateway"},
{"no HTTP", &MPCError{Op: "keygen", Message: "APIURL not configured"},
"mchain: keygen: APIURL not configured"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := tc.err.Error(); got != tc.want {
t.Errorf("got %q, want %q", got, tc.want)
}
})
}
}