Files
bridge/internal/txassembler/sol.go
T
zeekay 4dfb24878c feat(txassembler): SOL message build + signature finalize
Adds SOLAssembler — companion to the EVM Assembler — that constructs
Solana transactions for the bridge's destination-release flow:

  - PreSign: fetch a recent blockhash via JSON-RPC getLatestBlockhash,
    build a v0 message (one signer = the MPC release wallet as fee
    payer, one recipient, lamport amount), serialize the bytes to be
    signed. SPL token mode (when SourceMint is set) derives source +
    destination ATAs via solana.FindAssociatedTokenAddress and probes
    the destination ATA via getAccountInfo — if absent, the assembler
    prepends a CreateAssociatedTokenAccount instruction so the release
    wallet pays the ~2_039_280 lamport rent + creates the account.
  - Finalize: slot the 64-byte Ed25519 signature into Transaction
    .Signatures[0], serialize the full wire tx with MarshalBinary, and
    base64-encode for JSON-RPC sendTransaction (encoding:base64). The
    signature itself doubles as the canonical Solana "tx hash" — return
    it base58-encoded for SDK consumption.

Dependency: github.com/gagliardetto/solana-go v1.20.0 — the de facto
standard Go client. The assembler stays decoupled from the broader
*rpc.Client beyond GetLatestBlockhash + GetAccountInfo, so the
broadcast layer can use its own JSON-RPC endpoint.

Helpers:
  - LamportsFromFloat(amount, decimals): safe float → uint64 conversion
    with overflow protection. 9dp for native SOL; varies for SPL.
  - ParseSOLSignatureHex(s): decodes a 128-char hex string to a fixed
    [64]byte buffer — used by the signing driver when the dashboard
    returns the Ed25519 sig in hex form.
  - SOLDefaultBlockhashURL(network): canonical RPC URL per network.

Tests (sol_test.go) cover:
  - Native transfer happy path: instruction is system.Transfer at
    Account[0] = payer; round-trip of MessageBytes through
    Message.Unmarshal yields an identical message.
  - SPL transfer with destination ATA missing: 2 instructions
    (ata.Create, then token.Transfer), destination ATA correctly
    derived and probed.
  - SPL transfer with destination ATA present: only 1 instruction.
  - Finalize round-trip: rawTx parses back through solana-go's
    Transaction decoder with matching signature + payer + blockhash.
  - Error paths: unknown network, zero amount, malformed payer addr.
  - ParseSOLSignatureHex + LamportsFromFloat: corner cases.

Total: 12 SOL tests, all green.
2026-05-27 17:45:02 -07:00

475 lines
18 KiB
Go

// sol.go: Solana destination-chain transaction assembly. Companion to
// the EVM assembler in assembler.go but expressed in terms of Solana's
// own primitives (instructions, message, accounts, signatures).
//
// What we build:
// - Legacy / v0 messages: one fee payer (the MPC release wallet), one
// recipient, lamport amount (native SOL transfer) OR SPL token
// transfer (source ATA → destination ATA, with ATA creation when
// the destination ATA doesn't exist).
// - No address-lookup-tables. ALT support is a follow-up; the bridge
// only needs the basic shape for its release path today.
//
// Two-step ceremony — same shape as the EVM path:
// 1. PreSign: build the unsigned message, compute the bytes the MPC
// cluster will sign over (= raw message bytes; Solana doesn't hash
// before signing — Ed25519 hashes internally as part of the
// signing scheme).
// 2. Finalize: prepend the 64-byte signature in the canonical place
// and serialize the wire-ready transaction.
//
// The Solana wire transaction layout is:
//
// [compactU16: numSignatures]
// [64 bytes * numSignatures] ← signatures, in account_keys order
// [message bytes] ← header + accounts + recent_blockhash + instructions
//
// For the bridge's release tx, numSignatures is always 1 (the MPC
// release wallet is the sole signer + fee payer). The message bytes are
// what the MPC cluster signs. PreSign returns those bytes verbatim so
// the signing driver can hex-encode them and ship them to the cluster.
package txassembler
import (
"context"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"math"
"strings"
"github.com/gagliardetto/solana-go"
ata "github.com/gagliardetto/solana-go/programs/associated-token-account"
"github.com/gagliardetto/solana-go/programs/system"
"github.com/gagliardetto/solana-go/programs/token"
"github.com/gagliardetto/solana-go/rpc"
)
// =============================================================================
// Public types
// =============================================================================
// SOLNetworkConfig is the bridge-side config for one Solana network
// (mainnet vs devnet). Used to resolve the RPC endpoint that fetches
// the recent blockhash. Distinct from broadcast's RPC endpoint —
// callers can point them at different nodes (e.g. authenticated paid
// provider for sendTransaction, public RPC for blockhash polling).
type SOLNetworkConfig struct {
// BlockhashURL is the JSON-RPC endpoint used for getLatestBlockhash
// + getAccountInfo (ATA existence check). Required.
BlockhashURL string
// Commitment selects the freshness level for getLatestBlockhash.
// Solana defaults to "finalized"; "confirmed" gets a fresher hash
// at the cost of a slightly higher BlockhashNotFound risk. The
// bridge picks "confirmed" — release txs land within 1-2 slots so
// a 1-2 slot newer hash isn't a risk vs. a hash that may already
// be ~1 minute old by the time the MPC ceremony finishes.
Commitment rpc.CommitmentType
}
// SOLSpec is the Solana destination intent. Companion to SwapIntent
// for the EVM path — kept separate because Solana's parameters are
// fundamentally different (no nonce, no gas price, no chainID; just
// recent blockhash + lamports + accounts).
//
// Native SOL transfer (SourceMint empty): system.NewTransferInstruction
// from PayerAddress to RecipientAddress for LamportsAmount.
//
// SPL token transfer (SourceMint set): token.NewTransferInstruction
// from the source ATA (derived from PayerAddress + SourceMint) to the
// destination ATA (derived from RecipientAddress + SourceMint). When
// the destination ATA doesn't exist yet, the assembler prepends a
// CreateAssociatedTokenAccount instruction to mint it on the fly.
// Payer wallet covers both the transfer fee + the ATA rent-exempt
// minimum (~2_039_280 lamports as of 2026-05).
type SOLSpec struct {
// Network is the bridge internal network name, e.g. "SOLANA_DEVNET".
Network string
// PayerAddress is the release wallet's base58-encoded Ed25519
// public key — fees, fee payer signature, source of value.
PayerAddress string
// RecipientAddress is the destination wallet (user). Base58.
RecipientAddress string
// LamportsAmount is the value to transfer in lamports. For SPL,
// this is interpreted in the token's base units (not lamports);
// Solana doesn't distinguish "wei" vs "lamports" in the program
// arg — both system.Transfer and token.Transfer take uint64.
LamportsAmount uint64
// SourceMint, when non-empty, switches the assembler into SPL
// token transfer mode. Base58 mint address. Empty = native SOL.
SourceMint string
}
// SOLUnsigned is the intermediate representation produced by PreSign.
// Companion to *Unsigned for the EVM path. The signing driver feeds
// MessageBytes to the MPC cluster as the message-to-sign (hex), then
// passes the resulting 64-byte signature back through Finalize along
// with this struct to produce the wire-ready raw transaction.
//
// Why expose the *Transaction directly: solana-go's transaction has
// no public "set signatures" method, so Finalize has to hold a live
// handle to the tx to slot the signature in. Keeping it on Unsigned
// keeps the assembler stateless between calls.
type SOLUnsigned struct {
// Network mirrors SOLSpec.Network — useful when downstream code
// needs to route by network without holding the SOLSpec.
Network string
// Blockhash is the recent_blockhash baked into Message. Surfaced
// here so the signing driver can stash it on the swap record and
// detect BlockhashNotFound errors at broadcast time (the message
// must be rebuilt + re-signed with a fresher hash).
Blockhash string
// MessageBytes is the raw, canonical wire-encoded message — i.e.
// exactly what the Ed25519 signing function will hash + sign over.
// Sent to the MPC cluster as the message-to-sign.
MessageBytes []byte
// tx is the in-flight transaction. solana-go encodes signatures +
// message into the wire format via Transaction.MarshalBinary, so
// Finalize sets tx.Signatures[0] and re-marshals.
tx *solana.Transaction
// payer is captured so Finalize can confirm the signing driver
// hasn't fed it a signature from the wrong key.
payer solana.PublicKey
}
// SOLAssembler is the Solana-side counterpart to *Assembler. It speaks
// to a Solana RPC for blockhash + account-info lookups and produces
// SOLUnsigned values for the signing driver.
type SOLAssembler struct {
// Networks maps internal-name → per-network config. PreSign rejects
// unknown networks rather than silently defaulting.
Networks map[string]SOLNetworkConfig
// RPCFactory builds a solana-go *rpc.Client for the given URL.
// Kept as a field so tests can stub the network round-trip. Nil ⇒
// use rpc.New (the production constructor).
RPCFactory func(url string) *rpc.Client
}
// NewSOLAssembler builds an assembler with sensible defaults. Networks
// can be populated via SetNetwork.
func NewSOLAssembler() *SOLAssembler {
return &SOLAssembler{
Networks: map[string]SOLNetworkConfig{},
}
}
// SetNetwork registers a per-network config.
func (a *SOLAssembler) SetNetwork(network string, cfg SOLNetworkConfig) {
if cfg.Commitment == "" {
cfg.Commitment = rpc.CommitmentConfirmed
}
a.Networks[network] = cfg
}
// rpcFor builds (or stubs) the rpc.Client for the given URL.
func (a *SOLAssembler) rpcFor(url string) *rpc.Client {
if a.RPCFactory != nil {
return a.RPCFactory(url)
}
return rpc.New(url)
}
// =============================================================================
// PreSign
// =============================================================================
// ErrSOLNoNetworkConfig is returned by PreSign when SOLSpec.Network
// isn't in SOLAssembler.Networks. Callers must register the network
// (network YAML + bridge startup wiring) before PreSign can run.
var ErrSOLNoNetworkConfig = errors.New("txassembler: no SOL network config for that network")
// PreSign builds the unsigned Solana transaction. The returned
// SOLUnsigned.MessageBytes is the bytes-to-sign — feed it to the MPC
// cluster as the Ed25519 message.
//
// The flow:
// 1. Parse PayerAddress + RecipientAddress + (optional) SourceMint.
// 2. Fetch the recent blockhash from the network's RPC.
// 3. Build instructions:
// - Native: [system.Transfer(payer, recipient, lamports)]
// - SPL with destination ATA present:
// [token.Transfer(srcATA, dstATA, payer, lamports)]
// - SPL with destination ATA absent:
// [ata.Create(payer, recipient, mint),
// token.Transfer(srcATA, dstATA, payer, lamports)]
// 4. solana.NewTransaction(instructions, blockhash, WithPayer(payer))
// 5. Marshal the message portion to bytes — that's what gets signed.
//
// Errors propagated:
// - ErrSOLNoNetworkConfig network not registered.
// - solana.PublicKey parse errors malformed addresses.
// - rpc errors upstream RPC failure.
func (a *SOLAssembler) PreSign(ctx context.Context, in SOLSpec) (*SOLUnsigned, error) {
cfg, ok := a.Networks[in.Network]
if !ok {
return nil, fmt.Errorf("%w: %s", ErrSOLNoNetworkConfig, in.Network)
}
if cfg.BlockhashURL == "" {
return nil, fmt.Errorf("txassembler: SOL network %s missing BlockhashURL", in.Network)
}
payer, err := solana.PublicKeyFromBase58(in.PayerAddress)
if err != nil {
return nil, fmt.Errorf("txassembler: invalid PayerAddress %q: %w", in.PayerAddress, err)
}
recipient, err := solana.PublicKeyFromBase58(in.RecipientAddress)
if err != nil {
return nil, fmt.Errorf("txassembler: invalid RecipientAddress %q: %w", in.RecipientAddress, err)
}
if in.LamportsAmount == 0 {
return nil, errors.New("txassembler: LamportsAmount must be > 0")
}
client := a.rpcFor(cfg.BlockhashURL)
bh, err := client.GetLatestBlockhash(ctx, cfg.Commitment)
if err != nil {
return nil, fmt.Errorf("txassembler: getLatestBlockhash: %w", err)
}
if bh == nil || bh.Value == nil {
return nil, errors.New("txassembler: getLatestBlockhash returned empty result")
}
blockhash := bh.Value.Blockhash
// Build the instruction list.
instructions, err := a.buildSOLInstructions(ctx, client, payer, recipient, in)
if err != nil {
return nil, err
}
tx, err := solana.NewTransaction(
instructions,
blockhash,
solana.TransactionPayer(payer),
)
if err != nil {
return nil, fmt.Errorf("txassembler: NewTransaction: %w", err)
}
// MarshalBinary() on a Message produces the exact byte sequence that
// Solana signs — header + accountKeys + recentBlockhash + compiled
// instructions. The Transaction.MarshalBinary that includes
// signatures wraps these bytes with a [compactU16-prefixed] signature
// vector, but we don't have the signature yet, so we go straight to
// the message.
msgBytes, err := tx.Message.MarshalBinary()
if err != nil {
return nil, fmt.Errorf("txassembler: marshal SOL message: %w", err)
}
return &SOLUnsigned{
Network: in.Network,
Blockhash: blockhash.String(),
MessageBytes: msgBytes,
tx: tx,
payer: payer,
}, nil
}
// buildSOLInstructions resolves native vs SPL mode and ATA creation.
func (a *SOLAssembler) buildSOLInstructions(
ctx context.Context,
client *rpc.Client,
payer, recipient solana.PublicKey,
in SOLSpec,
) ([]solana.Instruction, error) {
if in.SourceMint == "" {
// Native SOL transfer.
return []solana.Instruction{
system.NewTransferInstruction(in.LamportsAmount, payer, recipient).Build(),
}, nil
}
mint, err := solana.PublicKeyFromBase58(in.SourceMint)
if err != nil {
return nil, fmt.Errorf("txassembler: invalid SourceMint %q: %w", in.SourceMint, err)
}
srcATA, _, err := solana.FindAssociatedTokenAddress(payer, mint)
if err != nil {
return nil, fmt.Errorf("txassembler: source ATA derive: %w", err)
}
dstATA, _, err := solana.FindAssociatedTokenAddress(recipient, mint)
if err != nil {
return nil, fmt.Errorf("txassembler: destination ATA derive: %w", err)
}
out := make([]solana.Instruction, 0, 2)
// Does the destination ATA already exist? Account-info nil ⇒ doesn't
// exist, prepend the ATA-create instruction. Any other error is
// transport / RPC failure — surface so the signing driver can retry.
exists, err := a.destATAExists(ctx, client, dstATA)
if err != nil {
return nil, fmt.Errorf("txassembler: probe destination ATA: %w", err)
}
if !exists {
out = append(out, ata.NewCreateInstruction(payer, recipient, mint).Build())
}
out = append(out,
token.NewTransferInstruction(in.LamportsAmount, srcATA, dstATA, payer, nil).Build(),
)
return out, nil
}
// destATAExists checks whether the destination ATA already exists on
// chain by calling getAccountInfo. true ⇒ no need to create. false ⇒
// must prepend the ATA-create instruction.
func (a *SOLAssembler) destATAExists(ctx context.Context, client *rpc.Client, addr solana.PublicKey) (bool, error) {
res, err := client.GetAccountInfo(ctx, addr)
if err != nil {
// solana-go surfaces "account not found" as nil result + nil
// error in newer releases, but historically also as
// rpc.ErrNotFound. Handle both shapes.
if errors.Is(err, rpc.ErrNotFound) {
return false, nil
}
// "could not find account" appears in some error strings —
// fall through to the substring check so we don't false-flag
// transient RPC errors as missing accounts.
low := strings.ToLower(err.Error())
if strings.Contains(low, "could not find account") || strings.Contains(low, "account not found") {
return false, nil
}
return false, err
}
if res == nil || res.Value == nil {
return false, nil
}
return true, nil
}
// =============================================================================
// Finalize
// =============================================================================
// SOLSignatureLen is the canonical Ed25519 signature size. Used by
// Finalize as a contract assertion before slotting the bytes in.
const SOLSignatureLen = 64
// Finalize takes the 64-byte Ed25519 signature from the MPC cluster
// and produces the wire-ready transaction as a base64 string.
//
// `sig` must be the raw 64-byte Ed25519 signature over MessageBytes.
// (Solana signatures are not "recoverable" — they're plain EdDSA with
// no v byte and no canonicalization.)
//
// The returned rawTxBase64 is ready to ship to JSON-RPC sendTransaction
// with `{encoding: "base64"}`. The returned signature is base58-encoded
// (Solana's canonical "tx hash" identifier).
func (a *SOLAssembler) Finalize(unsigned *SOLUnsigned, sig [SOLSignatureLen]byte) (rawTxBase64, signature string, err error) {
if unsigned == nil || unsigned.tx == nil {
return "", "", errors.New("txassembler: nil SOLUnsigned (call PreSign first)")
}
if len(unsigned.tx.Message.AccountKeys) == 0 {
return "", "", errors.New("txassembler: SOLUnsigned has empty AccountKeys (corrupt message)")
}
// Sanity: account[0] must be the payer (canonical Solana convention).
// solana.NewTransaction with TransactionPayer enforces this; reassert
// to catch any future regression.
if !unsigned.tx.Message.AccountKeys[0].Equals(unsigned.payer) {
return "", "", fmt.Errorf("txassembler: account[0] %s != payer %s",
unsigned.tx.Message.AccountKeys[0].String(),
unsigned.payer.String(),
)
}
var solSig solana.Signature
copy(solSig[:], sig[:])
// Solana wire requires len(signatures) == header.NumRequiredSignatures.
// For the bridge's release flow that's always 1 (just the payer).
unsigned.tx.Signatures = []solana.Signature{solSig}
raw, err := unsigned.tx.MarshalBinary()
if err != nil {
return "", "", fmt.Errorf("txassembler: marshal SOL tx: %w", err)
}
return base64.StdEncoding.EncodeToString(raw), solSig.String(), nil
}
// =============================================================================
// Signature byte conversion
// =============================================================================
// ParseSOLSignatureHex decodes a hex-encoded Ed25519 signature into a
// fixed-size 64-byte buffer. The dashboard returns Ed25519 signatures
// as 128 hex characters (64 bytes) in the "signature" JSON field;
// SignForWalletOnCurve doesn't post-process them, so the caller (the
// signing driver) parses them here.
//
// Accepts the input with or without a 0x prefix. Returns an error if
// the decoded length isn't exactly 64 bytes.
func ParseSOLSignatureHex(s string) ([SOLSignatureLen]byte, error) {
var out [SOLSignatureLen]byte
s = strings.TrimPrefix(strings.TrimPrefix(s, "0x"), "0X")
if len(s) != SOLSignatureLen*2 {
return out, fmt.Errorf("txassembler: SOL signature must be %d bytes (%d hex chars), got %d hex chars",
SOLSignatureLen, SOLSignatureLen*2, len(s))
}
dec, err := hex.DecodeString(s)
if err != nil {
return out, fmt.Errorf("txassembler: decode SOL signature hex: %w", err)
}
copy(out[:], dec)
return out, nil
}
// LamportsFromFloat converts a human-readable SOL/SPL amount to lamports
// (uint64) safely. decimals is the asset's decimals — 9 for native
// SOL, varies for SPL tokens. Rounds half-away-from-zero on the last
// digit to avoid silently dropping sub-lamport precision.
//
// Returns ErrSOLAmountOverflow when the result exceeds uint64; the
// bridge's typical amounts are well under that, so an overflow means
// the caller passed a corrupt input.
var ErrSOLAmountOverflow = errors.New("txassembler: SOL amount overflows uint64")
func LamportsFromFloat(amount float64, decimals int) (uint64, error) {
if amount < 0 {
return 0, fmt.Errorf("txassembler: SOL amount must be non-negative; got %v", amount)
}
if amount == 0 {
return 0, nil
}
scale := math.Pow10(decimals)
scaled := amount * scale
if math.IsInf(scaled, 0) || math.IsNaN(scaled) || scaled > math.MaxUint64 {
return 0, ErrSOLAmountOverflow
}
// Banker-style rounding to whole lamports.
return uint64(math.Round(scaled)), nil
}
// =============================================================================
// Helpers
// =============================================================================
// SOLDefaultBlockhashURL returns the default getLatestBlockhash URL for
// a known Solana network. Used by the bridge startup wiring when the
// operator hasn't supplied a custom URL via config / overrides.
func SOLDefaultBlockhashURL(network string) string {
switch network {
case "SOLANA_MAINNET":
return "https://api.mainnet-beta.solana.com"
case "SOLANA_DEVNET":
return "https://api.devnet.solana.com"
case "SOLANA_TESTNET":
return "https://api.testnet.solana.com"
default:
return ""
}
}