mirror of
https://github.com/luxfi/bridge.git
synced 2026-09-24 00:26:46 +00:00
luxd serves chain routes under /v1/chain, and the node, cli and sdk releases already ship it. The deployed gateways still answer the old prefix, so remote reads break until they are redeployed; the estate moves together rather than sitting half converted. Lines that record an observation - a probe, a status code, a past release - keep the prefix they were written against, as do URLs on hosts that are not ours. Avalanche endpoints move to /ext/bc, which is the path that host serves.
714 lines
21 KiB
Go
714 lines
21 KiB
Go
// JSON-RPC client tests for the BridgeVM (b-chain) Go port.
|
|
//
|
|
// BridgeVM isn't deployed on the live Lux nodes yet (re-verified
|
|
// 2026-07-27 on the canonical /v1 prefix: api.lux.network/v1/bc/B/rpc
|
|
// and the api.lux-test equivalent both 404, while /v1/bc/C/rpc answers
|
|
// 200 — so the 404 is "no such chain", not "no such prefix"), so these
|
|
// tests drive the client against httptest.Server
|
|
// mocks that return the canonical wire shapes documented in
|
|
// pkg/bridge/src/app/lib/bridge-rpc.ts. When BridgeVM lands, a second
|
|
// suite hitting the real wire can be added without changing client.go.
|
|
|
|
package bchain
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// mockNode wraps an httptest.Server with helpers for asserting on the
|
|
// JSON-RPC request shape and returning canned responses keyed by method
|
|
// name.
|
|
type mockNode struct {
|
|
t *testing.T
|
|
server *httptest.Server
|
|
responses map[string]func(req *jsonrpcRequest) jsonrpcResponse
|
|
calls atomic.Int64
|
|
// lastBody is the most recently received raw request body.
|
|
lastBody []byte
|
|
}
|
|
|
|
func newMockNode(t *testing.T) *mockNode {
|
|
t.Helper()
|
|
m := &mockNode{
|
|
t: t,
|
|
responses: make(map[string]func(*jsonrpcRequest) jsonrpcResponse),
|
|
}
|
|
m.server = httptest.NewServer(http.HandlerFunc(m.handle))
|
|
t.Cleanup(m.server.Close)
|
|
return m
|
|
}
|
|
|
|
func (m *mockNode) on(method string, fn func(*jsonrpcRequest) jsonrpcResponse) {
|
|
m.responses[method] = fn
|
|
}
|
|
|
|
// onResult is a shorthand for "return this static result for this method".
|
|
func (m *mockNode) onResult(method string, result any) {
|
|
raw, err := json.Marshal(result)
|
|
if err != nil {
|
|
m.t.Fatalf("mock onResult marshal: %v", err)
|
|
}
|
|
m.on(method, func(req *jsonrpcRequest) jsonrpcResponse {
|
|
return jsonrpcResponse{JSONRPC: "2.0", ID: req.ID, Result: raw}
|
|
})
|
|
}
|
|
|
|
// onError makes the node respond with a JSON-RPC error envelope.
|
|
func (m *mockNode) onError(method string, code int, message string) {
|
|
m.on(method, func(req *jsonrpcRequest) jsonrpcResponse {
|
|
return jsonrpcResponse{
|
|
JSONRPC: "2.0",
|
|
ID: req.ID,
|
|
Error: &jsonrpcError{Code: code, Message: message},
|
|
}
|
|
})
|
|
}
|
|
|
|
func (m *mockNode) handle(w http.ResponseWriter, r *http.Request) {
|
|
m.calls.Add(1)
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if got := r.Header.Get("Content-Type"); got != "application/json" {
|
|
m.t.Errorf("mock node: expected Content-Type application/json, got %q", got)
|
|
}
|
|
body, _ := io.ReadAll(r.Body)
|
|
m.lastBody = body
|
|
var req jsonrpcRequest
|
|
if err := json.Unmarshal(body, &req); err != nil {
|
|
http.Error(w, "bad json", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req.JSONRPC != "2.0" {
|
|
m.t.Errorf("mock node: expected jsonrpc=2.0, got %q", req.JSONRPC)
|
|
}
|
|
if req.ID == "" {
|
|
m.t.Error("mock node: missing request id")
|
|
}
|
|
fn, ok := m.responses[req.Method]
|
|
if !ok {
|
|
http.Error(w, fmt.Sprintf("no mock for method %q", req.Method), http.StatusNotImplemented)
|
|
return
|
|
}
|
|
resp := fn(&req)
|
|
if resp.ID == "" {
|
|
resp.ID = req.ID
|
|
}
|
|
if resp.JSONRPC == "" {
|
|
resp.JSONRPC = "2.0"
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
// clientForMock builds a Client pointed at the mock. We set the bridge
|
|
// URL only (threshold URL stays empty so the thresholdCall guard test
|
|
// can fire).
|
|
func clientForMock(m *mockNode) *Client {
|
|
return &Client{BridgeRPCURL: m.server.URL, Timeout: 2 * time.Second}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Happy-path tests — one per method, mirrors TS bridge-rpc.ts coverage.
|
|
// =============================================================================
|
|
|
|
func TestEstimateFee_HappyPath(t *testing.T) {
|
|
m := newMockNode(t)
|
|
m.onResult("bridge_estimateFee", FeeEstimate{
|
|
FeeAmount: "0.001",
|
|
NetAmount: "0.099",
|
|
EstimatedTime: 180,
|
|
})
|
|
c := clientForMock(m)
|
|
|
|
got, err := c.EstimateFee(context.Background(), EstimateFeeParams{
|
|
SourceChain: "ETHEREUM_SEPOLIA",
|
|
DestChain: "LUX_TESTNET",
|
|
SourceAsset: "ETH",
|
|
DestAsset: "LUX",
|
|
Amount: "0.1",
|
|
Refuel: false,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("EstimateFee err: %v", err)
|
|
}
|
|
if got.FeeAmount != "0.001" || got.NetAmount != "0.099" || got.EstimatedTime != 180 {
|
|
t.Fatalf("unexpected fee estimate: %+v", got)
|
|
}
|
|
|
|
// Verify the request body contains the right method + params.
|
|
var req jsonrpcRequest
|
|
if err := json.Unmarshal(m.lastBody, &req); err != nil {
|
|
t.Fatalf("decode last body: %v", err)
|
|
}
|
|
if req.Method != "bridge_estimateFee" {
|
|
t.Errorf("expected method bridge_estimateFee, got %q", req.Method)
|
|
}
|
|
rawParams, _ := json.Marshal(req.Params)
|
|
if !strings.Contains(string(rawParams), `"sourceChain":"ETHEREUM_SEPOLIA"`) {
|
|
t.Errorf("params missing sourceChain: %s", rawParams)
|
|
}
|
|
if !strings.Contains(string(rawParams), `"destChain":"LUX_TESTNET"`) {
|
|
t.Errorf("params missing destChain: %s", rawParams)
|
|
}
|
|
}
|
|
|
|
func TestSubmitBridgeRequest_HappyPath(t *testing.T) {
|
|
m := newMockNode(t)
|
|
m.onResult("bridge_submitRequest", BridgeRequest{
|
|
RequestID: "req_abc123",
|
|
SourceChain: "ETHEREUM_SEPOLIA",
|
|
DestChain: "LUX_TESTNET",
|
|
SourceAsset: "ETH",
|
|
DestAsset: "LUX",
|
|
Amount: "0.1",
|
|
Recipient: "0xa28fAE14eB42e7A5C36Ad2D774a2b7Eb293c4473",
|
|
Sender: "0xa28fAE14eB42e7A5C36Ad2D774a2b7Eb293c4473",
|
|
Status: StatusPending,
|
|
CreatedAt: 1718000000,
|
|
})
|
|
c := clientForMock(m)
|
|
|
|
got, err := c.SubmitBridgeRequest(context.Background(), SubmitRequestParams{
|
|
SourceChain: "ETHEREUM_SEPOLIA",
|
|
DestChain: "LUX_TESTNET",
|
|
SourceAsset: "ETH",
|
|
DestAsset: "LUX",
|
|
Amount: "0.1",
|
|
Recipient: "0xa28fAE14eB42e7A5C36Ad2D774a2b7Eb293c4473",
|
|
Sender: "0xa28fAE14eB42e7A5C36Ad2D774a2b7Eb293c4473",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("SubmitBridgeRequest err: %v", err)
|
|
}
|
|
if got.RequestID != "req_abc123" || got.Status != StatusPending {
|
|
t.Fatalf("unexpected request: %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestGetBridgeStatus_PhaseTransition(t *testing.T) {
|
|
m := newMockNode(t)
|
|
// Each call advances the status, mimicking the live polling loop in
|
|
// useTransfers.ts so we exercise the full BridgeRequestStatus enum.
|
|
phases := []BridgeRequestStatus{
|
|
StatusPending, StatusDeposited, StatusSigning,
|
|
StatusSigned, StatusReleasing, StatusCompleted,
|
|
}
|
|
var idx atomic.Int64
|
|
m.on("bridge_getStatus", func(req *jsonrpcRequest) jsonrpcResponse {
|
|
n := idx.Add(1) - 1
|
|
if n >= int64(len(phases)) {
|
|
n = int64(len(phases)) - 1
|
|
}
|
|
raw, _ := json.Marshal(BridgeRequest{
|
|
RequestID: "req_abc123",
|
|
Status: phases[n],
|
|
})
|
|
return jsonrpcResponse{JSONRPC: "2.0", ID: req.ID, Result: raw}
|
|
})
|
|
c := clientForMock(m)
|
|
|
|
for i, want := range phases {
|
|
got, err := c.GetBridgeStatus(context.Background(), "req_abc123")
|
|
if err != nil {
|
|
t.Fatalf("call %d: %v", i, err)
|
|
}
|
|
if got.Status != want {
|
|
t.Errorf("call %d: status = %q, want %q", i, got.Status, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGetBridgeInfo_HappyPath(t *testing.T) {
|
|
m := newMockNode(t)
|
|
m.onResult("bridge_getInfo", BridgeInfo{
|
|
Version: "1.0.0",
|
|
NodeID: "NodeID-XYZ",
|
|
ChainID: "B",
|
|
MPCReady: true,
|
|
MPCPublicKey: "0xdeadbeef",
|
|
Threshold: 3,
|
|
TotalParties: 5,
|
|
SupportedChains: []string{"ETHEREUM_MAINNET", "LUX_MAINNET"},
|
|
TotalBridged: "1000000",
|
|
TotalFees: "1000",
|
|
})
|
|
c := clientForMock(m)
|
|
|
|
got, err := c.GetBridgeInfo(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetBridgeInfo err: %v", err)
|
|
}
|
|
if !got.MPCReady || got.Threshold != 3 || got.TotalParties != 5 {
|
|
t.Fatalf("unexpected info: %+v", got)
|
|
}
|
|
if len(got.SupportedChains) != 2 {
|
|
t.Errorf("expected 2 supported chains, got %d", len(got.SupportedChains))
|
|
}
|
|
}
|
|
|
|
func TestGetSupportedChains_HappyPath(t *testing.T) {
|
|
m := newMockNode(t)
|
|
m.onResult("bridge_getSupportedChains", []ChainConfig{
|
|
{ChainID: "1", ChainName: "Ethereum", Enabled: true,
|
|
TokenContracts: map[string]string{"USDC": "0xa0b8...e3"}},
|
|
{ChainID: "11155111", ChainName: "Sepolia", Enabled: true,
|
|
TokenContracts: map[string]string{}},
|
|
})
|
|
c := clientForMock(m)
|
|
|
|
got, err := c.GetSupportedChains(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetSupportedChains err: %v", err)
|
|
}
|
|
if len(got) != 2 {
|
|
t.Fatalf("expected 2 chains, got %d", len(got))
|
|
}
|
|
if got[0].ChainID != "1" || got[0].TokenContracts["USDC"] == "" {
|
|
t.Errorf("unexpected first chain: %+v", got[0])
|
|
}
|
|
}
|
|
|
|
func TestHealth_HappyPath(t *testing.T) {
|
|
m := newMockNode(t)
|
|
m.onResult("bridge_health", Health{Status: "ok", MPCReady: true})
|
|
c := clientForMock(m)
|
|
|
|
got, err := c.Health(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Health err: %v", err)
|
|
}
|
|
if got.Status != "ok" || !got.MPCReady {
|
|
t.Fatalf("unexpected health: %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestGetMPCPublicKey_HappyPath(t *testing.T) {
|
|
m := newMockNode(t)
|
|
m.onResult("bridge_getMPCPublicKey", MPCPublicKey{PublicKey: "0xabcdef"})
|
|
c := clientForMock(m)
|
|
|
|
got, err := c.GetMPCPublicKey(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetMPCPublicKey err: %v", err)
|
|
}
|
|
if got.PublicKey != "0xabcdef" {
|
|
t.Fatalf("unexpected pubkey: %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestGetBridgeSignature_HappyPath(t *testing.T) {
|
|
m := newMockNode(t)
|
|
m.onResult("bridge_getSignature", BridgeSignature{
|
|
Signature: "0x" + strings.Repeat("ab", 32),
|
|
SessionID: "sess_42",
|
|
})
|
|
c := clientForMock(m)
|
|
|
|
got, err := c.GetBridgeSignature(context.Background(), "req_abc123")
|
|
if err != nil {
|
|
t.Fatalf("GetBridgeSignature err: %v", err)
|
|
}
|
|
if got.SessionID != "sess_42" || len(got.Signature) != 66 {
|
|
t.Fatalf("unexpected signature: %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestCancelRequest_HappyPath(t *testing.T) {
|
|
m := newMockNode(t)
|
|
m.onResult("bridge_cancelRequest", CancelResult{Success: true})
|
|
c := clientForMock(m)
|
|
|
|
got, err := c.CancelRequest(context.Background(), "req_abc123")
|
|
if err != nil {
|
|
t.Fatalf("CancelRequest err: %v", err)
|
|
}
|
|
if !got.Success {
|
|
t.Fatalf("expected success, got %+v", got)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Error-path tests
|
|
// =============================================================================
|
|
|
|
func TestRPCError_FromNode(t *testing.T) {
|
|
m := newMockNode(t)
|
|
m.onError("bridge_estimateFee", -32602, "invalid params: amount must be positive")
|
|
c := clientForMock(m)
|
|
|
|
_, err := c.EstimateFee(context.Background(), EstimateFeeParams{Amount: "-1"})
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
var rpcErr *RPCError
|
|
if !errors.As(err, &rpcErr) {
|
|
t.Fatalf("expected *RPCError, got %T", err)
|
|
}
|
|
if rpcErr.Code != -32602 || !strings.Contains(rpcErr.Message, "invalid params") {
|
|
t.Errorf("unexpected rpc error: %+v", rpcErr)
|
|
}
|
|
if rpcErr.Method != "bridge_estimateFee" {
|
|
t.Errorf("expected method tag bridge_estimateFee, got %q", rpcErr.Method)
|
|
}
|
|
}
|
|
|
|
func TestRPCError_HTTPNon200(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusBadGateway)
|
|
_, _ = w.Write([]byte("upstream unhappy"))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
c := &Client{BridgeRPCURL: srv.URL, Timeout: time.Second}
|
|
|
|
_, err := c.Health(context.Background())
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
var rpcErr *RPCError
|
|
if !errors.As(err, &rpcErr) {
|
|
t.Fatalf("expected *RPCError, got %T", err)
|
|
}
|
|
if rpcErr.HTTPStatus != http.StatusBadGateway {
|
|
t.Errorf("expected HTTPStatus 502, got %d", rpcErr.HTTPStatus)
|
|
}
|
|
if !strings.Contains(rpcErr.Message, "HTTP 502") {
|
|
t.Errorf("expected message to mention HTTP 502: %q", rpcErr.Message)
|
|
}
|
|
}
|
|
|
|
func TestRPCError_EmptyResult(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(`{"jsonrpc":"2.0","id":"x","result":null}`))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
c := &Client{BridgeRPCURL: srv.URL, Timeout: time.Second}
|
|
|
|
_, err := c.Health(context.Background())
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
var rpcErr *RPCError
|
|
if !errors.As(err, &rpcErr) || rpcErr.Code != -32603 {
|
|
t.Errorf("expected -32603 empty result, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRPCError_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(`this is not json`))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
c := &Client{BridgeRPCURL: srv.URL, Timeout: time.Second}
|
|
|
|
_, err := c.Health(context.Background())
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
var rpcErr *RPCError
|
|
if !errors.As(err, &rpcErr) {
|
|
t.Fatalf("expected *RPCError, got %T", err)
|
|
}
|
|
if !strings.Contains(rpcErr.Message, "decode response") {
|
|
t.Errorf("expected decode-response error, got: %q", rpcErr.Message)
|
|
}
|
|
}
|
|
|
|
func TestContextCanceled(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Block long enough that the canceled context aborts the call.
|
|
select {
|
|
case <-r.Context().Done():
|
|
case <-time.After(2 * time.Second):
|
|
}
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
c := &Client{BridgeRPCURL: srv.URL, Timeout: 5 * time.Second}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel() // already canceled before the call
|
|
|
|
_, err := c.Health(ctx)
|
|
if err == nil {
|
|
t.Fatal("expected cancellation error, got nil")
|
|
}
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Errorf("expected context.Canceled, got %T: %v", err, err)
|
|
}
|
|
}
|
|
|
|
func TestTimeoutFiresWithoutContext(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Sleep longer than the client's per-request timeout.
|
|
select {
|
|
case <-r.Context().Done():
|
|
case <-time.After(2 * time.Second):
|
|
}
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
c := &Client{BridgeRPCURL: srv.URL, Timeout: 200 * time.Millisecond}
|
|
|
|
start := time.Now()
|
|
_, err := c.Health(context.Background())
|
|
dur := time.Since(start)
|
|
if err == nil {
|
|
t.Fatal("expected timeout error, got nil")
|
|
}
|
|
if dur > 1*time.Second {
|
|
t.Errorf("expected timeout < 1s, took %v", dur)
|
|
}
|
|
if !errors.Is(err, context.DeadlineExceeded) {
|
|
t.Errorf("expected context.DeadlineExceeded, got %T: %v", err, err)
|
|
}
|
|
}
|
|
|
|
func TestThresholdCall_NotConfigured(t *testing.T) {
|
|
// BridgeRPCURL set, ThresholdRPCURL deliberately blank.
|
|
c := &Client{BridgeRPCURL: "http://localhost:0", Timeout: time.Second}
|
|
err := c.thresholdCall(context.Background(), "anything", nil, nil)
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
var rpcErr *RPCError
|
|
if !errors.As(err, &rpcErr) {
|
|
t.Fatalf("expected *RPCError, got %T", err)
|
|
}
|
|
if !strings.Contains(rpcErr.Message, "ThresholdRPCURL not configured") {
|
|
t.Errorf("unexpected message: %q", rpcErr.Message)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Constructor + id generation
|
|
// =============================================================================
|
|
|
|
func TestNew_DerivesURLs(t *testing.T) {
|
|
c := New("https://api.lux-test.network", 0)
|
|
if c.BridgeRPCURL != "https://api.lux-test.network/v1/chain/B/rpc" {
|
|
t.Errorf("BridgeRPCURL: %q", c.BridgeRPCURL)
|
|
}
|
|
if c.ThresholdRPCURL != "https://api.lux-test.network/v1/chain/T/rpc" {
|
|
t.Errorf("ThresholdRPCURL: %q", c.ThresholdRPCURL)
|
|
}
|
|
if c.Timeout != 0 {
|
|
t.Errorf("Timeout should be 0 when unset, got %v", c.Timeout)
|
|
}
|
|
}
|
|
|
|
func TestNew_DefaultsToLocalhost(t *testing.T) {
|
|
c := New("", 5*time.Second)
|
|
if c.BridgeRPCURL != "http://127.0.0.1:9650/v1/chain/B/rpc" {
|
|
t.Errorf("expected localhost default, got %q", c.BridgeRPCURL)
|
|
}
|
|
if c.Timeout != 5*time.Second {
|
|
t.Errorf("Timeout: %v", c.Timeout)
|
|
}
|
|
}
|
|
|
|
func TestNextID_UniqueAndShaped(t *testing.T) {
|
|
c := &Client{}
|
|
seen := map[string]bool{}
|
|
for i := 0; i < 100; i++ {
|
|
id := c.nextID()
|
|
if seen[id] {
|
|
t.Fatalf("duplicate id at iter %d: %q", i, id)
|
|
}
|
|
seen[id] = true
|
|
if !strings.Contains(id, "-") {
|
|
t.Errorf("expected '-' in id %q", id)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRPCRoundTrip_RequestShape(t *testing.T) {
|
|
m := newMockNode(t)
|
|
m.onResult("bridge_health", Health{Status: "ok", MPCReady: true})
|
|
c := clientForMock(m)
|
|
|
|
if _, err := c.Health(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var got jsonrpcRequest
|
|
if err := json.Unmarshal(m.lastBody, &got); err != nil {
|
|
t.Fatalf("decode last body: %v", err)
|
|
}
|
|
if got.JSONRPC != "2.0" {
|
|
t.Errorf("JSONRPC = %q, want 2.0", got.JSONRPC)
|
|
}
|
|
if got.Method != "bridge_health" {
|
|
t.Errorf("Method = %q", got.Method)
|
|
}
|
|
if got.ID == "" {
|
|
t.Error("ID empty")
|
|
}
|
|
if got.Params != nil {
|
|
t.Errorf("Params should be omitted for nil-param method, got %v", got.Params)
|
|
}
|
|
if m.calls.Load() != 1 {
|
|
t.Errorf("expected 1 call, got %d", m.calls.Load())
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// LP-333: signer set + epoch coordination
|
|
// =============================================================================
|
|
|
|
func TestGetSignerSetInfo_HappyPath(t *testing.T) {
|
|
m := newMockNode(t)
|
|
m.onResult("bridge_getSignerSetInfo", SignerSetInfo{
|
|
Members: []SignerMember{
|
|
{NodeID: "node-0", PublicKey: "0x" + strings.Repeat("a", 64), Address: "0xAAAA"},
|
|
{NodeID: "node-1", PublicKey: "0x" + strings.Repeat("b", 64), Address: "0xBBBB"},
|
|
{NodeID: "node-2", PublicKey: "0x" + strings.Repeat("c", 64), Address: "0xCCCC"},
|
|
},
|
|
Threshold: 2,
|
|
Total: 3,
|
|
Epoch: 7,
|
|
SignerSetHash: "0xdeadbeef",
|
|
LastRotationAt: 1730000000,
|
|
})
|
|
c := clientForMock(m)
|
|
|
|
got, err := c.GetSignerSetInfo(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetSignerSetInfo err: %v", err)
|
|
}
|
|
if got.Threshold != 2 {
|
|
t.Errorf("Threshold = %d, want 2", got.Threshold)
|
|
}
|
|
if got.Total != 3 {
|
|
t.Errorf("Total = %d, want 3", got.Total)
|
|
}
|
|
if got.Epoch != 7 {
|
|
t.Errorf("Epoch = %d, want 7", got.Epoch)
|
|
}
|
|
if got.SignerSetHash != "0xdeadbeef" {
|
|
t.Errorf("SignerSetHash = %q, want 0xdeadbeef", got.SignerSetHash)
|
|
}
|
|
if len(got.Members) != 3 || got.Members[0].NodeID != "node-0" {
|
|
t.Errorf("Members roster wrong: %+v", got.Members)
|
|
}
|
|
}
|
|
|
|
// TestGetSignerSetInfo_DerivesTotalFromMembers covers the defensive
|
|
// path: an upstream that returns the Members roster but doesn't fill
|
|
// Total should get a client-side fill-in so consumers don't have to
|
|
// recount.
|
|
func TestGetSignerSetInfo_DerivesTotalFromMembers(t *testing.T) {
|
|
m := newMockNode(t)
|
|
m.onResult("bridge_getSignerSetInfo", SignerSetInfo{
|
|
Members: []SignerMember{
|
|
{NodeID: "node-0"}, {NodeID: "node-1"}, {NodeID: "node-2"}, {NodeID: "node-3"},
|
|
},
|
|
Threshold: 3,
|
|
// Total intentionally omitted.
|
|
Epoch: 12,
|
|
})
|
|
c := clientForMock(m)
|
|
|
|
got, err := c.GetSignerSetInfo(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetSignerSetInfo err: %v", err)
|
|
}
|
|
if got.Total != 4 {
|
|
t.Errorf("Total = %d, want 4 (derived from len(Members))", got.Total)
|
|
}
|
|
}
|
|
|
|
// TestGetSignerSetInfo_MethodNotFound documents the back-compat
|
|
// contract: an upstream BridgeVM without LP-333 returns -32601, which
|
|
// the client surfaces as a typed RPCError. Callers fall back to
|
|
// "assumed state" mode.
|
|
func TestGetSignerSetInfo_MethodNotFound(t *testing.T) {
|
|
m := newMockNode(t)
|
|
m.onError("bridge_getSignerSetInfo", -32601, "method not found")
|
|
c := clientForMock(m)
|
|
|
|
_, err := c.GetSignerSetInfo(context.Background())
|
|
if err == nil {
|
|
t.Fatal("expected RPCError, got nil")
|
|
}
|
|
var rpcErr *RPCError
|
|
if !errors.As(err, &rpcErr) {
|
|
t.Fatalf("expected *RPCError, got %T", err)
|
|
}
|
|
if rpcErr.Code != -32601 {
|
|
t.Errorf("Code = %d, want -32601 (method not found)", rpcErr.Code)
|
|
}
|
|
}
|
|
|
|
func TestGetCurrentEpoch_HappyPath(t *testing.T) {
|
|
m := newMockNode(t)
|
|
m.onResult("bridge_getCurrentEpoch", CurrentEpoch{
|
|
Epoch: 9,
|
|
SignerSetHash: "0xfeedface",
|
|
StartedAt: 1730000000,
|
|
})
|
|
c := clientForMock(m)
|
|
|
|
got, err := c.GetCurrentEpoch(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetCurrentEpoch err: %v", err)
|
|
}
|
|
if got.Epoch != 9 {
|
|
t.Errorf("Epoch = %d, want 9", got.Epoch)
|
|
}
|
|
if got.SignerSetHash != "0xfeedface" {
|
|
t.Errorf("SignerSetHash = %q, want 0xfeedface", got.SignerSetHash)
|
|
}
|
|
if got.StartedAt != 1730000000 {
|
|
t.Errorf("StartedAt = %d, want 1730000000", got.StartedAt)
|
|
}
|
|
}
|
|
|
|
func TestGetCurrentEpoch_MethodNotFound(t *testing.T) {
|
|
m := newMockNode(t)
|
|
m.onError("bridge_getCurrentEpoch", -32601, "method not found")
|
|
c := clientForMock(m)
|
|
|
|
_, err := c.GetCurrentEpoch(context.Background())
|
|
var rpcErr *RPCError
|
|
if !errors.As(err, &rpcErr) || rpcErr.Code != -32601 {
|
|
t.Errorf("expected -32601 RPCError, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRPCError_String(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
err *RPCError
|
|
want string
|
|
}{
|
|
{
|
|
name: "with http status",
|
|
err: &RPCError{Method: "bridge_health", HTTPStatus: 502, Message: "bad gateway"},
|
|
want: "bchain: bridge_health HTTP 502: bad gateway",
|
|
},
|
|
{
|
|
name: "rpc error",
|
|
err: &RPCError{Method: "bridge_estimateFee", Code: -32602, Message: "invalid params"},
|
|
want: "bchain: bridge_estimateFee rpc -32602: invalid params",
|
|
},
|
|
}
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|