feat(mchain): derive substrate SS58 client-side; deprecate ErrSubstrateNotImplemented

The bridge previously fast-failed DOT keygen with ErrSubstrateNotImplemented.
The MPC cluster already returns a 33-byte compressed secp256k1 pubkey
(ecdsa_pub_key) — the only missing piece was the SS58 derivation, which
internal/substrate now provides.

Changes:
- Wallet.ECDSAPubKey: new field carrying the compressed pubkey hex.
  Required by the DOT signing path so the assembler can derive the
  AccountId and resolve the ECDSA recovery byte at Finalize.
- pickAddress: AddressTypeDOT now derives SS58 from ECDSAPubKey via
  blake2_256(pub) -> AccountId32 -> base58check with the per-network
  prefix (mainnet=0, Westend=42, Kusama=2). Returns ErrMissingPubKey
  if the cluster forgot to populate ecdsa_pub_key.
- networkAddressType: add POLKADOT_TESTNET, KUSAMA_MAINNET as DOT
  family (alongside the existing POLKADOT_MAINNET entry).
- ErrSubstrateNotImplemented: kept exported (callers may errors.Is)
  but documented as deprecated. KeygenForDeposit no longer returns it.

Tests updated:
- TestKeygen_DOT_DerivesSS58FromPubKey: pin a deterministic pubkey,
  verify the SS58 address starts with '1' (Polkadot prefix 0).
- TestKeygen_DOT_TestnetPrefix: Westend (prefix 42) addresses start
  with '5'.
- TestKeygen_DOT_MissingPubKeyErrors: empty ecdsa_pub_key -> ErrMissingPubKey.
- cmd/bridge swap handler test: TestSwapsCreate_DOTRequest_Succeeds
  replaces the old "returns 501" expectation.
This commit is contained in:
2026-05-27 18:01:37 -07:00
parent bdfadf528c
commit 84d4d56cf4
4 changed files with 194 additions and 37 deletions
+27 -11
View File
@@ -125,6 +125,12 @@ func mockBchain(t *testing.T, on map[string]any) *httptest.Server {
// mpcMock serves a fake MPC /keygen endpoint.
func mpcMock(t *testing.T, eth, btc, sol string) *httptest.Server {
return mpcMockFull(t, eth, btc, sol, "")
}
// mpcMockFull is mpcMock with an explicit ecdsa_pub_key — needed for
// substrate (DOT) tests where the bridge derives SS58 client-side.
func mpcMockFull(t *testing.T, eth, btc, sol, ecdsaPub string) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/keygen" || r.Method != http.MethodPost {
@@ -133,11 +139,12 @@ func mpcMock(t *testing.T, eth, btc, sol string) *httptest.Server {
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"wallet_id": "bridge-mock-1718000000",
"eth_address": eth,
"btc_address": btc,
"sol_address": sol,
"result_type": "success",
"wallet_id": "bridge-mock-1718000000",
"eth_address": eth,
"btc_address": btc,
"sol_address": sol,
"ecdsa_pub_key": ecdsaPub,
"result_type": "success",
})
}))
t.Cleanup(srv.Close)
@@ -499,8 +506,12 @@ func TestSwapsCreate_UseDepositAddressTrue_NoMPC_Returns503(t *testing.T) {
}
}
func TestSwapsCreate_DOTRequest_Returns501(t *testing.T) {
mpc := mpcMock(t, "0xeth", "", "")
func TestSwapsCreate_DOTRequest_Succeeds(t *testing.T) {
// As of the DOT integration, substrate keygens succeed: bridge derives
// SS58 client-side from the cluster's ecdsa_pub_key. Verify the swap
// is created with a Polkadot-style address.
const pub = "02bf3e72a73be7a3a1b9b7872c7e3a7bf1c5e22f4e7f2a73be7a3a1b9b7872c7e3"
mpc := mpcMockFull(t, "0xeth", "", "", pub)
mc := &mchain.Client{APIURL: mpc.URL, OrgID: "test-org", Timeout: 2 * time.Second}
rig := newRig(t, nil, mc, nil)
@@ -512,11 +523,16 @@ func TestSwapsCreate_DOTRequest_Returns501(t *testing.T) {
UseDepositAddress: true,
})
status, body := fireRequest(t, rig.app, http.MethodPost, "/v1/bridge/swaps", reqBody)
if status != http.StatusNotImplemented {
t.Fatalf("status = %d, want 501. body=%s", status, body)
if status != http.StatusOK {
t.Fatalf("status = %d, want 200. body=%s", status, body)
}
if !strings.Contains(string(body), "unsupported_chain") {
t.Errorf("expected unsupported_chain, got %s", body)
if !strings.Contains(string(body), `"source_network":"POLKADOT_MAINNET"`) {
t.Errorf("response missing source_network=POLKADOT_MAINNET: %s", body)
}
// Deposit address should be the SS58 string after the legacy ### separator.
// For Polkadot mainnet, SS58 starts with '1'.
if !strings.Contains(string(body), `###1`) {
t.Errorf("expected SS58 deposit_address (starts with '1' after ###), got %s", body)
}
}
+49 -16
View File
@@ -97,6 +97,8 @@ var networkAddressType = map[string]AddressType{
"XRP_TESTNET": AddressTypeXRP,
// Polkadot / Substrate
"POLKADOT_MAINNET": AddressTypeDOT,
"POLKADOT_TESTNET": AddressTypeDOT,
"KUSAMA_MAINNET": AddressTypeDOT,
// Cardano — placeholder (Ed25519); use the sol address slot until a
// proper Cardano encoder lands. Matches TS mpc-wallet.ts behaviour.
"CARDANO_MAINNET": AddressTypeSOL,
@@ -143,6 +145,13 @@ type Wallet struct {
// AddressType is the family of Address. Useful for downstream code
// that needs to render or validate the address.
AddressType AddressType
// ECDSAPubKey is the 33-byte compressed secp256k1 pubkey, hex-encoded.
// Populated whenever the cluster returns a non-empty ecdsa_pub_key
// (i.e. all ECDSA-derived families: EVM, BTC, XRP, DOT-ECDSA).
// Required by the DOT signing path so the assembler can derive the
// signing AccountId and determine the ECDSA recovery byte at
// Finalize time. Empty for Ed25519-derived families (SOL, TON).
ECDSAPubKey string
}
// LegacyDepositString returns the "wallet_name###address" string the
@@ -171,15 +180,28 @@ func (e *MPCError) Error() string {
return fmt.Sprintf("mchain: %s: %s", e.Op, e.Message)
}
// ErrSubstrateNotImplemented is returned when a Polkadot/Substrate
// keygen is requested. Substrate addresses require SS58 encoding from
// the ed25519 public key; the Go bridge doesn't depend on an SS58
// library yet. Surfaces as a clear error so callers know to wait or
// route the swap through a different path.
// ErrSubstrateNotImplemented historically signalled "DOT keygen
// unsupported by the bridge". As of the DOT integration the bridge
// derives the SS58 address client-side from the cluster's
// ecdsa_pub_key, so this error is no longer returned by KeygenForDeposit.
// Kept exported for callers that historically branched on errors.Is —
// they'll never see it return now, but the symbol stays a stable
// part of the API.
//
// Deprecated: as of bridge v2.x.x DOT is supported; do not branch on
// errors.Is against this value any more.
var ErrSubstrateNotImplemented = errors.New(
"mchain: substrate (dot) address derivation not implemented; needs SS58 encoder",
)
// ErrMissingPubKey is returned when a substrate (DOT) keygen succeeds
// but the cluster didn't populate ecdsa_pub_key — substrate ECDSA
// AccountId derivation needs the compressed pubkey. Surfaces clearly
// so the operator knows the MPC cluster's response shape changed.
var ErrMissingPubKey = errors.New(
"mchain: substrate keygen response missing ecdsa_pub_key — cluster cannot derive SS58 address",
)
// =============================================================================
// Client
// =============================================================================
@@ -367,14 +389,7 @@ func (c *Client) KeygenForDepositWithOrg(ctx context.Context, networkInternalNam
if c.APIURL == "" {
return nil, &MPCError{Op: "keygen", Message: "APIURL not configured"}
}
// DOT check fires BEFORE the org_id check: a DOT keygen can't
// succeed regardless of auth, so the more informative
// ErrSubstrateNotImplemented wins.
addrType := AddressTypeFor(networkInternalName)
if addrType == AddressTypeDOT {
return nil, ErrSubstrateNotImplemented
}
if orgID == "" {
return nil, &MPCError{Op: "keygen", Message: "org_id required (set Client.OrgID or pass per-call)"}
}
@@ -444,7 +459,7 @@ func (c *Client) KeygenForDepositWithOrg(ctx context.Context, networkInternalNam
}
}
address, err := pickAddress(&result, addrType)
address, err := pickAddress(&result, addrType, networkInternalName)
if err != nil {
return nil, err
}
@@ -461,6 +476,7 @@ func (c *Client) KeygenForDepositWithOrg(ctx context.Context, networkInternalNam
Name: name,
Address: address,
AddressType: addrType,
ECDSAPubKey: result.ECDSAPubKey,
}, nil
}
@@ -617,7 +633,11 @@ func (c *Client) SignForWalletWithOrg(ctx context.Context, walletID, messageHex,
// pickAddress mirrors the TS switch on AddressType. Returns an
// *MPCError when the chosen slot is empty.
func pickAddress(r *keygenResult, t AddressType) (string, error) {
//
// The networkInternalName is passed through (rather than only the
// AddressType) so SS58-flavored families can pick the right network
// prefix (Polkadot mainnet vs Kusama vs Westend/generic testnet).
func pickAddress(r *keygenResult, t AddressType, networkInternalName string) (string, error) {
var addr string
switch t {
case AddressTypeBTC:
@@ -633,8 +653,21 @@ func pickAddress(r *keygenResult, t AddressType) (string, error) {
// explicit placeholder in mpc-wallet.ts.
addr = r.ETHAddress
case AddressTypeDOT:
// Should never be reached — caller guards via ErrSubstrateNotImplemented.
return "", ErrSubstrateNotImplemented
// Substrate ECDSA AccountId = blake2_256(compressed_pubkey),
// then SS58-encoded with the network-specific prefix. Derive
// client-side from the ecdsa_pub_key the cluster returns —
// the mpcd daemon doesn't (yet) emit SS58 strings natively.
if r.ECDSAPubKey == "" {
return "", ErrMissingPubKey
}
ss58, err := deriveSubstrateSS58(r.ECDSAPubKey, networkInternalName)
if err != nil {
return "", &MPCError{
Op: "keygen",
Message: fmt.Sprintf("derive SS58 address: %v", err),
}
}
addr = ss58
default: // AddressTypeETH or unknown → eth
addr = r.ETHAddress
}
+61 -10
View File
@@ -89,6 +89,8 @@ func TestAddressTypeFor_Coverage(t *testing.T) {
{"TON_MAINNET", AddressTypeTON},
{"XRP_TESTNET", AddressTypeXRP},
{"POLKADOT_MAINNET", AddressTypeDOT},
{"POLKADOT_TESTNET", AddressTypeDOT},
{"KUSAMA_MAINNET", AddressTypeDOT},
{"CARDANO_MAINNET", AddressTypeSOL}, // placeholder slot
{"UNKNOWN_NETWORK", AddressTypeETH}, // default fallback
}
@@ -223,18 +225,67 @@ func TestKeygen_XRP_UsesETHSlot(t *testing.T) {
}
}
func TestKeygen_DOT_ReturnsErrSubstrateNotImplemented(t *testing.T) {
// Important: we should NOT hit the cluster — fail fast in the
// AddressTypeDOT branch.
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)
c := clientFor(m)
_, err := c.KeygenForDeposit(context.Background(), "POLKADOT_MAINNET")
if !errors.Is(err, ErrSubstrateNotImplemented) {
t.Fatalf("expected ErrSubstrateNotImplemented, got %v", err)
m.result = &keygenResult{
WalletID: "bridge-polkadot_mainnet-1",
ECDSAPubKey: pub,
ResultType: "success",
}
if len(m.lastBody) != 0 {
t.Errorf("cluster should not have been hit for DOT request, body=%s", m.lastBody)
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 w.ECDSAPubKey != pub {
t.Errorf("ECDSAPubKey = %q", 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)
}
}
+57
View File
@@ -0,0 +1,57 @@
// dot.go: substrate-flavoured derivation helpers for the keygen path.
//
// The MPC cluster returns a hex-encoded compressed secp256k1 pubkey
// (ecdsa_pub_key) for every ECDSA keygen. For substrate (DOT/Kusama),
// the bridge derives an SS58 address client-side rather than waiting
// for the cluster to learn the SS58 algorithm. This keeps the cluster
// generic and lets the bridge support new substrate chains by just
// adding a prefix entry to substrateNetworkPrefix.
package mchain
import (
"encoding/hex"
"fmt"
"strings"
"github.com/luxfi/bridge/internal/substrate"
)
// substrateNetworkPrefix maps a bridge internal_name → SS58 prefix.
// Add new substrate-flavoured chains here as the bridge supports them.
// Defaults to SS58Generic (42) for unknown DOT-family chains.
var substrateNetworkPrefix = map[string]substrate.SS58Prefix{
"POLKADOT_MAINNET": substrate.SS58PolkadotMainnet, // 0
"POLKADOT_TESTNET": substrate.SS58Generic, // 42 (Westend)
"KUSAMA_MAINNET": substrate.SS58Kusama, // 2
}
// SubstratePrefixFor returns the SS58 prefix for a substrate-flavoured
// network. Falls back to SS58Generic (42) for any unknown name —
// matches the substrate-generic encoding most testnets use, and is
// safe (the on-chain runtime accepts the raw AccountId regardless).
func SubstratePrefixFor(networkInternalName string) substrate.SS58Prefix {
if p, ok := substrateNetworkPrefix[networkInternalName]; ok {
return p
}
return substrate.SS58Generic
}
// deriveSubstrateSS58 takes the hex-encoded 33-byte compressed pubkey
// the MPC cluster returns and derives the substrate ECDSA address (an
// SS58 string) for the given network.
//
// The bridge stores BOTH the SS58 address and the raw pubkey on the
// Wallet record so the signing path can use either.
func deriveSubstrateSS58(pubkeyHex, networkInternalName string) (string, error) {
pubkeyHex = strings.TrimPrefix(strings.TrimPrefix(pubkeyHex, "0x"), "0X")
pub, err := hex.DecodeString(pubkeyHex)
if err != nil {
return "", fmt.Errorf("decode hex pubkey: %w", err)
}
acc, err := substrate.AccountIDFromECDSAPub(pub)
if err != nil {
return "", err
}
return substrate.SS58Encode(acc, SubstratePrefixFor(networkInternalName))
}