mirror of
https://github.com/luxfi/bridge.git
synced 2026-09-24 00:26:46 +00:00
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:
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user