QUIC Transaction Submission

Use QUIC when you want to send signed transaction bytes to NextBlock with minimal transport overhead from Go.

The pattern is simple:

  1. Dial a regional endpoint such as frankfurt.nextblock.io:11100

  2. Authenticate once on a bidirectional stream

  3. Reuse the same QUIC connection and open one unidirectional stream per transaction

Example

package main

import (
	"context"
	"crypto/tls"
	"fmt"
	"io"
	"net"
	"os"
	"time"

	quic "github.com/quic-go/quic-go"
)

const (
	authOK    byte = 0x00
	maxTxSize      = 1232
)

type NextblockQuicClient struct {
	conn quic.Connection
}

func Connect(ctx context.Context, serverAddr string, apiKey string) (*NextblockQuicClient, error) {
	host, _, err := net.SplitHostPort(serverAddr)
	if err != nil {
		return nil, fmt.Errorf("invalid server address: %w", err)
	}

	conn, err := quic.DialAddr(ctx, serverAddr, &tls.Config{
		ServerName: host,
		NextProtos: []string{"nb-tx/1"},
		MinVersion: tls.VersionTLS13,
	}, &quic.Config{
		KeepAlivePeriod: 15 * time.Second,
		MaxIdleTimeout:  60 * time.Second,
	})
	if err != nil {
		return nil, fmt.Errorf("quic dial failed: %w", err)
	}

	authStream, err := conn.OpenStreamSync(ctx)
	if err != nil {
		conn.CloseWithError(1, "auth stream failed")
		return nil, fmt.Errorf("open auth stream: %w", err)
	}

	if _, err := authStream.Write([]byte(apiKey)); err != nil {
		conn.CloseWithError(1, "auth write failed")
		return nil, fmt.Errorf("write api key: %w", err)
	}
	if err := authStream.Close(); err != nil {
		conn.CloseWithError(1, "auth close failed")
		return nil, fmt.Errorf("close auth stream: %w", err)
	}

	response := make([]byte, 1)
	if _, err := io.ReadFull(authStream, response); err != nil {
		conn.CloseWithError(1, "auth read failed")
		return nil, fmt.Errorf("read auth response: %w", err)
	}
	if response[0] != authOK {
		conn.CloseWithError(1, "auth rejected")
		return nil, fmt.Errorf("authentication rejected")
	}

	return &NextblockQuicClient{conn: conn}, nil
}

func (c *NextblockQuicClient) SendTransaction(ctx context.Context, rawTx []byte) error {
	if len(rawTx) > maxTxSize {
		return fmt.Errorf("transaction too large: %d", len(rawTx))
	}

	stream, err := c.conn.OpenUniStreamSync(ctx)
	if err != nil {
		return fmt.Errorf("open tx stream: %w", err)
	}

	if _, err := stream.Write(rawTx); err != nil {
		return fmt.Errorf("write tx bytes: %w", err)
	}

	return stream.Close()
}

func (c *NextblockQuicClient) Close() error {
	return c.conn.CloseWithError(0, "client closing")
}

func main() {
	ctx := context.Background()
	apiKey := os.Getenv("NEXTBLOCK_API_KEY")
	if apiKey == "" {
		panic("set NEXTBLOCK_API_KEY before running")
	}

	client, err := Connect(ctx, "frankfurt.nextblock.io:11100", apiKey)
	if err != nil {
		panic(err)
	}
	defer client.Close()

	// Replace this with the serialized bytes of your signed Solana transaction.
	// Example with solana-go: rawTx, err := tx.MarshalBinary()
	rawTx := []byte{0, 1, 2, 3}

	if err := client.SendTransaction(ctx, rawTx); err != nil {
		panic(err)
	}

	fmt.Println("transaction queued")
}

What To Replace

  • Swap frankfurt.nextblock.io:11100 for your nearest region.

  • Replace rawTx with the serialized bytes of your signed transaction.

  • Keep one NextblockQuicClient alive and reuse it instead of reconnecting per send.

Notes

  • Send raw transaction bytes, not base64.

  • Each transaction goes on its own unidirectional stream.

  • QUIC does not support the extra gRPC submission flags or atomic bundle submission.

  • If you already build transactions with solana-go, serialize the signed transaction and pass those bytes into SendTransaction().

See QUIC Transaction Submission for the full endpoint list and protocol summary.

Last updated