mirror of
https://github.com/luxfi/bridge.git
synced 2026-09-24 00:26:46 +00:00
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.
933 lines
29 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|