mirror of
https://github.com/luxfi/bridge.git
synced 2026-09-24 00:26:46 +00:00
Three layered changes to drive Solana to the broadcast leg of the pipeline:
1. internal/broadcast/sol.go — sendTransaction over JSON-RPC.
broadcastSOL packs the base64-encoded signed tx that the assembler
produced into a Solana sendTransaction call with
{encoding:"base64", preflightCommitment:"confirmed", skipPreflight:false}.
preflightCommitment matches the assembler's blockhash commitment so
the cluster's preflight simulation runs against a consistent
snapshot.
Two typed errors mirror the canonical upstream messages:
- SOLBlockhashNotFoundError — Retryable() == true. The signing
driver should drop the current message, rebuild PreSign with a
fresh blockhash, re-run the MPC ceremony.
- SOLSimulationError — Retryable() == false. Deterministic failure
(insufficient SPL balance, malformed instruction, missing ATA);
same MPC ceremony won't fix it. Logs extracted from error.data
are surfaced for operator triage.
Anything else falls through to the existing *RPCError shape.
client.go: register SOLANA_{MAINNET,DEVNET,TESTNET} in rpcURLs +
dispatch AddressTypeSOL → broadcastSOL.
2. balance_probe.go — getBalance for Solana (lamports).
BalanceAt now branches on network family:
- EVM: eth_getBalance, returns wei
- SOL: getBalance with commitment=confirmed, returns lamports
- Other non-EVM (BTC/TON/XRP/DOT): ErrFamilyNotSupportedForBalance,
gas pre-check skipped (preserves existing behaviour)
The new doRPC helper extracts shared HTTP transport so the EVM and
SOL paths don't duplicate boilerplate. Lamport result decoded
through json.Number → *big.Int for safety even though it always
fits in uint64.
3. cmd/bridge/release_pool_set.go + release_pool_family_store.go —
per-family release-wallet pool routing.
The existing *ReleasePool is single-family by design (one mint
network, one rotation cursor). Multi-family deployments need
independent pools per family because the keygen call, address
format, balance unit, and funding flow are all family-scoped.
ReleasePoolSet wraps {EVM, SOL, BTC} *ReleasePool pointers and
dispatches Acquire by mchain.AddressTypeFor(destinationNetwork). A
nil per-family pointer surfaces ErrNoPoolForFamily so the signing
driver falls back to the deposit-as-release legacy path.
Family-scoped persistence (ZapStoreFamily / InMemoryStoreFamily)
keys entries under releasepool:<family>:NNNNNN. The legacy
releasepool:NNNNNN namespace stays intact for backward compat with
pre-multi-family deployments. FamilyEVM/SOL/BTC constants are
wire-stable (changing them breaks reload of existing pools).
The InMemoryStore now holds an extra familyPools map alongside the
single-pool slot — the legacy pool path stays exactly as it was;
multi-family layers in.
Tests:
- internal/broadcast/sol_test.go: happy path, malformed response,
BlockhashNotFound retryable classification, SimulationError fatal
classification + log extraction, empty raw tx, empty result,
classifySOLError direct unit coverage, RPC URL table presence.
- cmd/bridge/release_pool_set_test.go: family routing (EVM vs SOL),
per-family round-robin isolation, Size + FamilySizes diagnostics,
InMemoryStoreFamily namespace isolation + ascending-index iter,
FamilyCode constant pinning.
All package tests green:
ok cmd/bridge 3.957s
ok internal/broadcast (cached)
ok internal/mchain (cached)
ok internal/txassembler (cached)
267 lines
8.7 KiB
Go
267 lines
8.7 KiB
Go
// Tests for the Solana destination broadcaster.
|
|
|
|
package broadcast
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// solSendServer is a minimal httptest harness that handles
|
|
// sendTransaction calls and emits a configurable response.
|
|
type solSendServer struct {
|
|
server *httptest.Server
|
|
resultSig string
|
|
errCode int
|
|
errMsg string
|
|
errData json.RawMessage
|
|
calls int
|
|
lastTxB64 string
|
|
lastParams []any
|
|
}
|
|
|
|
func newSolSendServer(t *testing.T, resultSig string) *solSendServer {
|
|
t.Helper()
|
|
s := &solSendServer{resultSig: resultSig}
|
|
s.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
s.calls++
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
var req struct {
|
|
Method string `json:"method"`
|
|
Params []any `json:"params"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
if req.Method != "sendTransaction" {
|
|
http.Error(w, "wrong method "+req.Method, http.StatusBadRequest)
|
|
return
|
|
}
|
|
s.lastParams = req.Params
|
|
if len(req.Params) > 0 {
|
|
if str, ok := req.Params[0].(string); ok {
|
|
s.lastTxB64 = str
|
|
}
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if s.errMsg != "" {
|
|
payload := map[string]any{
|
|
"jsonrpc": "2.0", "id": 1,
|
|
"error": map[string]any{"code": s.errCode, "message": s.errMsg},
|
|
}
|
|
if len(s.errData) > 0 {
|
|
payload["error"].(map[string]any)["data"] = json.RawMessage(s.errData)
|
|
}
|
|
_ = json.NewEncoder(w).Encode(payload)
|
|
return
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"jsonrpc": "2.0", "id": 1, "result": s.resultSig,
|
|
})
|
|
}))
|
|
t.Cleanup(s.server.Close)
|
|
return s
|
|
}
|
|
|
|
// =============================================================================
|
|
// Happy path
|
|
// =============================================================================
|
|
|
|
func TestBroadcastSOL_Happy(t *testing.T) {
|
|
srv := newSolSendServer(t, "5J7XU…fakeSig")
|
|
c := &Client{Timeout: time.Second, RPCURLOverrides: map[string]string{
|
|
"SOLANA_DEVNET": srv.server.URL,
|
|
}}
|
|
res, err := c.Broadcast(context.Background(), "SOLANA_DEVNET", "BASE64SIGNED_TX==")
|
|
if err != nil {
|
|
t.Fatalf("err: %v", err)
|
|
}
|
|
if res.TxHash != "5J7XU…fakeSig" {
|
|
t.Errorf("TxHash = %q", res.TxHash)
|
|
}
|
|
if srv.calls != 1 {
|
|
t.Errorf("calls = %d, want 1", srv.calls)
|
|
}
|
|
// Inspect the params shape: ["<base64>", {encoding, preflightCommitment, skipPreflight}].
|
|
if len(srv.lastParams) != 2 {
|
|
t.Fatalf("params len = %d, want 2", len(srv.lastParams))
|
|
}
|
|
if tx, _ := srv.lastParams[0].(string); tx != "BASE64SIGNED_TX==" {
|
|
t.Errorf("first param = %q, want raw tx b64", tx)
|
|
}
|
|
opts, _ := srv.lastParams[1].(map[string]any)
|
|
if opts == nil {
|
|
t.Fatal("second param must be opts map")
|
|
}
|
|
if got, _ := opts["encoding"].(string); got != "base64" {
|
|
t.Errorf("encoding = %q, want base64", got)
|
|
}
|
|
if got, _ := opts["preflightCommitment"].(string); got != "confirmed" {
|
|
t.Errorf("preflightCommitment = %q, want confirmed", got)
|
|
}
|
|
if got, _ := opts["skipPreflight"].(bool); got != false {
|
|
t.Errorf("skipPreflight = %v, want false", got)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Malformed RPC response
|
|
// =============================================================================
|
|
|
|
func TestBroadcastSOL_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{Timeout: time.Second, RPCURLOverrides: map[string]string{
|
|
"SOLANA_DEVNET": srv.URL,
|
|
}}
|
|
_, err := c.Broadcast(context.Background(), "SOLANA_DEVNET", "BASE64==")
|
|
if err == nil {
|
|
t.Fatal("expected malformed-response error")
|
|
}
|
|
if !strings.Contains(err.Error(), "decode") {
|
|
t.Errorf("expected decode error, got %v", err)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// BlockhashNotFound — retryable
|
|
// =============================================================================
|
|
|
|
func TestBroadcastSOL_BlockhashNotFoundIsRetryable(t *testing.T) {
|
|
srv := newSolSendServer(t, "")
|
|
srv.errCode = -32002
|
|
srv.errMsg = "Blockhash not found"
|
|
c := &Client{Timeout: time.Second, RPCURLOverrides: map[string]string{
|
|
"SOLANA_DEVNET": srv.server.URL,
|
|
}}
|
|
_, err := c.Broadcast(context.Background(), "SOLANA_DEVNET", "BASE64==")
|
|
if err == nil {
|
|
t.Fatal("expected blockhash-not-found error")
|
|
}
|
|
var bn *SOLBlockhashNotFoundError
|
|
if !errors.As(err, &bn) {
|
|
t.Fatalf("expected *SOLBlockhashNotFoundError, got %T: %v", err, err)
|
|
}
|
|
if !bn.Retryable() {
|
|
t.Errorf("BlockhashNotFound must be retryable")
|
|
}
|
|
if !IsSOLBlockhashNotFound(err) {
|
|
t.Errorf("IsSOLBlockhashNotFound returned false")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// SimulationError — fatal
|
|
// =============================================================================
|
|
|
|
func TestBroadcastSOL_SimulationErrorIsFatal(t *testing.T) {
|
|
srv := newSolSendServer(t, "")
|
|
srv.errCode = -32002
|
|
srv.errMsg = "Transaction simulation failed: insufficient funds for fee"
|
|
srv.errData = json.RawMessage(`{"err":"InsufficientFundsForFee","logs":["Program log: hello","Program log: insufficient"]}`)
|
|
c := &Client{Timeout: time.Second, RPCURLOverrides: map[string]string{
|
|
"SOLANA_DEVNET": srv.server.URL,
|
|
}}
|
|
_, err := c.Broadcast(context.Background(), "SOLANA_DEVNET", "BASE64==")
|
|
if err == nil {
|
|
t.Fatal("expected simulation error")
|
|
}
|
|
var se *SOLSimulationError
|
|
if !errors.As(err, &se) {
|
|
t.Fatalf("expected *SOLSimulationError, got %T: %v", err, err)
|
|
}
|
|
if se.Retryable() {
|
|
t.Errorf("SimulationError must NOT be retryable")
|
|
}
|
|
if got := len(se.Logs); got != 2 {
|
|
t.Errorf("expected 2 logs extracted from error.data, got %d (logs=%v)", got, se.Logs)
|
|
}
|
|
if !IsSOLSimulationError(err) {
|
|
t.Errorf("IsSOLSimulationError returned false")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Empty raw tx
|
|
// =============================================================================
|
|
|
|
func TestBroadcastSOL_EmptyRawTx(t *testing.T) {
|
|
c := &Client{Timeout: time.Second, RPCURLOverrides: map[string]string{
|
|
"SOLANA_DEVNET": "http://unused",
|
|
}}
|
|
_, err := c.Broadcast(context.Background(), "SOLANA_DEVNET", "")
|
|
if !errors.Is(err, ErrEmptyRawTx) {
|
|
t.Errorf("expected ErrEmptyRawTx, got %v", err)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Empty signature in response
|
|
// =============================================================================
|
|
|
|
func TestBroadcastSOL_EmptySignature(t *testing.T) {
|
|
srv := newSolSendServer(t, "") // resultSig deliberately empty
|
|
c := &Client{Timeout: time.Second, RPCURLOverrides: map[string]string{
|
|
"SOLANA_DEVNET": srv.server.URL,
|
|
}}
|
|
_, err := c.Broadcast(context.Background(), "SOLANA_DEVNET", "BASE64==")
|
|
var rpcErr *RPCError
|
|
if !errors.As(err, &rpcErr) {
|
|
t.Fatalf("expected *RPCError, got %T", err)
|
|
}
|
|
if !strings.Contains(rpcErr.Message, "empty signature") {
|
|
t.Errorf("expected empty-signature message, got %q", rpcErr.Message)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// classifySOLError — direct unit coverage
|
|
// =============================================================================
|
|
|
|
func TestClassifySOLError(t *testing.T) {
|
|
if !IsSOLBlockhashNotFound(classifySOLError("Blockhash not found", nil)) {
|
|
t.Errorf("expected blockhash-not-found classification (exact)")
|
|
}
|
|
if !IsSOLBlockhashNotFound(classifySOLError("BLOCKHASH NOT FOUND on slot 12345", nil)) {
|
|
t.Errorf("classification should be case-insensitive")
|
|
}
|
|
if !IsSOLSimulationError(classifySOLError("Transaction simulation failed: InsufficientFundsForFee", nil)) {
|
|
t.Errorf("expected simulation classification")
|
|
}
|
|
if !IsSOLSimulationError(classifySOLError("preflight failure", nil)) {
|
|
t.Errorf("expected simulation classification on preflight string")
|
|
}
|
|
// Default → generic RPCError.
|
|
err := classifySOLError("node is behind by 50 slots", nil)
|
|
var rpcErr *RPCError
|
|
if !errors.As(err, &rpcErr) {
|
|
t.Errorf("expected generic *RPCError, got %T: %v", err, err)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// RPC URL table
|
|
// =============================================================================
|
|
|
|
func TestRPCURLFor_SOL(t *testing.T) {
|
|
if RPCURLFor("SOLANA_MAINNET") == "" {
|
|
t.Error("SOLANA_MAINNET should be in the table")
|
|
}
|
|
if RPCURLFor("SOLANA_DEVNET") == "" {
|
|
t.Error("SOLANA_DEVNET should be in the table")
|
|
}
|
|
if RPCURLFor("SOLANA_TESTNET") == "" {
|
|
t.Error("SOLANA_TESTNET should be in the table")
|
|
}
|
|
}
|