Files
zeekay dd4a5e3d61 feat(bridge): SOL broadcaster, lamport balance probe, per-family pools
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)
2026-05-27 17:52:15 -07:00

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")
}
}