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

197 lines
7.1 KiB
Go

// sol.go: Solana destination broadcaster. Hands a signed, base64-
// encoded transaction to a Solana RPC via the standard sendTransaction
// JSON-RPC method.
//
// Wire shape:
//
// POST <rpc URL>
// {"jsonrpc":"2.0","id":N,"method":"sendTransaction",
// "params":["<base64 tx>",
// {"encoding":"base64","preflightCommitment":"confirmed","skipPreflight":false}]}
//
// Success response: {"jsonrpc":"2.0","id":N,"result":"<base58 signature>"}.
// The signature returned by sendTransaction IS the transaction's
// canonical identifier on Solana — no separate "tx hash". Surfaced as
// BroadcastResult.TxHash for consistency with the rest of the bridge
// pipeline.
//
// Error surfacing: sendTransaction's `error.data` is a structured
// object on simulation failures (with `logs`, `unitsConsumed`,
// `accounts`, `err`) and a plain string on transport-level errors. We
// inspect `error.message` for the canonical strings and route to:
// - SOLBlockhashNotFoundError → retryable=true, the bridge should
// rebuild + re-sign with a fresh hash.
// - SOLSimulationError → retryable=false, the tx is bad
// (e.g. SPL transfer with insufficient
// token balance, malformed instruction).
// - *RPCError → everything else.
package broadcast
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
)
// =============================================================================
// Errors
// =============================================================================
// SOLBlockhashNotFoundError is returned when the destination node
// rejects the tx because the embedded recent_blockhash is no longer
// in its block cache. Retryable — the signing driver should rebuild
// the unsigned tx with a fresh blockhash and re-run the MPC ceremony.
// (The signature is over the message bytes, which include the
// blockhash, so a stale blockhash means a stale signature.)
type SOLBlockhashNotFoundError struct {
// Underlying carries the upstream RPC message for triage.
Underlying string
}
func (e *SOLBlockhashNotFoundError) Error() string {
return "broadcast: SOL BlockhashNotFound — recent_blockhash expired, rebuild + re-sign: " + e.Underlying
}
// Retryable reports the error to the signing driver as a "build + sign
// again" hint rather than a hard failure. The signing driver should
// re-run PreSign (fresh blockhash) and Finalize (fresh signature) then
// re-broadcast.
func (e *SOLBlockhashNotFoundError) Retryable() bool { return true }
// SOLSimulationError is returned when the cluster's preflight
// simulation rejected the tx. Hard failure — same MPC ceremony will
// keep failing because the simulation result is deterministic given
// the same on-chain state. Operators must inspect logs to determine
// the cause (insufficient SPL balance, ATA owner mismatch, etc.).
type SOLSimulationError struct {
// Message is the upstream error.message string.
Message string
// Logs is the program-log array extracted from error.data.logs
// when the cluster supplied one. Empty on transport-level errors.
Logs []string
}
func (e *SOLSimulationError) Error() string {
if len(e.Logs) == 0 {
return "broadcast: SOL simulation failed: " + e.Message
}
return fmt.Sprintf("broadcast: SOL simulation failed: %s (logs=%d entries)", e.Message, len(e.Logs))
}
// Retryable reports false — simulation failures are deterministic.
func (e *SOLSimulationError) Retryable() bool { return false }
// =============================================================================
// broadcastSOL
// =============================================================================
// broadcastSOL pushes a base64-encoded signed transaction to the
// destination Solana RPC. rawTxB64 must be the base64 string produced
// by txassembler.SOLAssembler.Finalize.
func (c *Client) broadcastSOL(ctx context.Context, url, rawTxB64 string) (*BroadcastResult, error) {
// Tolerate trim — the txassembler returns vanilla base64 (no
// padding stripped, no URL encoding) but be defensive about callers
// that re-encoded it.
rawTxB64 = strings.TrimSpace(rawTxB64)
if rawTxB64 == "" {
return nil, ErrEmptyRawTx
}
type solSendResp struct {
Result string `json:"result"`
Error *struct {
Code int `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data,omitempty"`
} `json:"error,omitempty"`
}
// preflightCommitment=confirmed matches the blockhash commitment
// the assembler used — keeps both sides in sync so we don't get
// blockhash-not-found just because preflight ran against a fresher
// snapshot than the blockhash came from.
//
// skipPreflight=false: we want the cluster's simulation surface so
// BlockhashNotFound and SimulationError both surface here rather
// than getting silently dropped at first inclusion attempt.
params := []any{
rawTxB64,
map[string]any{
"encoding": "base64",
"preflightCommitment": "confirmed",
"skipPreflight": false,
},
}
var resp solSendResp
if err := c.jsonRPC(ctx, url, "sendTransaction", params, &resp); err != nil {
return nil, err
}
if resp.Error != nil {
return nil, classifySOLError(resp.Error.Message, resp.Error.Data)
}
if resp.Result == "" {
return nil, &RPCError{
Method: "sendTransaction",
Code: -32603,
Message: "empty signature",
}
}
return &BroadcastResult{TxHash: resp.Result}, nil
}
// classifySOLError maps an upstream JSON-RPC error into a typed error
// shape so the signing driver / broadcast driver can branch on:
// - SOLBlockhashNotFoundError (retryable)
// - SOLSimulationError (fatal)
// - generic *RPCError otherwise
//
// Canonical upstream strings (from agave / solana-validator):
// - "Blockhash not found" → BlockhashNotFoundError
// - "Transaction simulation failed: …" → SimulationError
// - "Node is behind by N slots" → generic RPCError (retry)
func classifySOLError(message string, data json.RawMessage) error {
lc := strings.ToLower(message)
if strings.Contains(lc, "blockhash not found") {
return &SOLBlockhashNotFoundError{Underlying: message}
}
if strings.Contains(lc, "transaction simulation failed") ||
strings.Contains(lc, "preflight failure") ||
strings.Contains(lc, "preflight check") {
simErr := &SOLSimulationError{Message: message}
if len(data) > 0 {
// Try to pull logs out of the {err, logs:[…], …} payload.
var d struct {
Logs []string `json:"logs"`
}
if json.Unmarshal(data, &d) == nil {
simErr.Logs = d.Logs
}
}
return simErr
}
return &RPCError{
Method: "sendTransaction",
Message: message,
Data: data,
}
}
// IsSOLBlockhashNotFound reports whether err is (or wraps) a
// SOLBlockhashNotFoundError. Convenience for callers that want a
// boolean predicate without errors.As ceremony.
func IsSOLBlockhashNotFound(err error) bool {
var bn *SOLBlockhashNotFoundError
return errors.As(err, &bn)
}
// IsSOLSimulationError reports whether err is (or wraps) a
// SOLSimulationError.
func IsSOLSimulationError(err error) bool {
var se *SOLSimulationError
return errors.As(err, &se)
}