QUIC Transaction Submission

Use QUIC when you want the lowest-overhead path for sending signed Solana transaction bytes to NextBlock from Rust.

The flow is:

  1. Connect to a regional QUIC endpoint such as london.nextblock.io:11100

  2. Authenticate once with your API key on a bidirectional stream

  3. Reuse the connection and send each transaction on its own unidirectional stream

Example

use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;

use anyhow::{anyhow, Context, Result};
use quinn::{ClientConfig, Connection, Endpoint, TransportConfig};
use tokio::io::{AsyncReadExt, AsyncWriteExt};

const ALPN_NB_TX: &[u8] = b"nb-tx/1";
const AUTH_OK: u8 = 0x00;
const MAX_TX_SIZE: usize = 1232;

pub struct NextblockQuicClient {
    // Keep the endpoint alive for as long as the connection is in use.
    endpoint: Endpoint,
    connection: Connection,
}

impl NextblockQuicClient {
    pub async fn connect(server_addr: &str, api_key: &str) -> Result<Self> {
        let endpoint = create_endpoint()?;

        let addr: SocketAddr = tokio::net::lookup_host(server_addr)
            .await
            .context("dns lookup failed")?
            .next()
            .ok_or_else(|| anyhow!("no addresses found for {server_addr}"))?;

        let server_name = server_addr
            .split(':')
            .next()
            .ok_or_else(|| anyhow!("invalid server address"))?;

        let connection = endpoint
            .connect(addr, server_name)?
            .await
            .context("quic handshake failed")?;

        let (mut send, mut recv) = connection
            .open_bi()
            .await
            .context("failed to open auth stream")?;

        send.write_all(api_key.as_bytes())
            .await
            .context("failed to send api key")?;
        send.finish().context("failed to close auth stream")?;

        let mut response = [0u8; 1];
        recv.read_exact(&mut response)
            .await
            .context("failed to read auth response")?;

        if response[0] != AUTH_OK {
            return Err(anyhow!("authentication rejected"));
        }

        Ok(Self { endpoint, connection })
    }

    pub async fn send_transaction(&self, raw_tx: &[u8]) -> Result<()> {
        if raw_tx.len() > MAX_TX_SIZE {
            return Err(anyhow!("transaction too large: {}", raw_tx.len()));
        }

        let mut send = self
            .connection
            .open_uni()
            .await
            .context("failed to open tx stream")?;

        send.write_all(raw_tx)
            .await
            .context("failed to write transaction")?;
        send.finish().context("failed to finish tx stream")?;

        Ok(())
    }
}

impl Drop for NextblockQuicClient {
    fn drop(&mut self) {
        self.connection.close(quinn::VarInt::from_u32(0), b"client closing");
    }
}

fn create_endpoint() -> Result<Endpoint> {
    let mut roots = rustls::RootCertStore::empty();
    let native_certs = rustls_native_certs::load_native_certs();
    for cert in native_certs.certs {
        roots.add(cert).ok();
    }

    let mut crypto = rustls::ClientConfig::builder()
        .with_root_certificates(roots)
        .with_no_client_auth();
    crypto.alpn_protocols = vec![ALPN_NB_TX.to_vec()];

    let mut transport = TransportConfig::default();
    transport.max_idle_timeout(Some(quinn::IdleTimeout::try_from(Duration::from_secs(60))?));
    transport.keep_alive_interval(Some(Duration::from_secs(15)));

    let mut client_config = ClientConfig::new(Arc::new(
        quinn::crypto::rustls::QuicClientConfig::try_from(crypto)?,
    ));
    client_config.transport_config(Arc::new(transport));

    let mut endpoint = Endpoint::client("0.0.0.0:0".parse()?)?;
    endpoint.set_default_client_config(client_config);

    Ok(endpoint)
}

#[tokio::main]
async fn main() -> Result<()> {
    let api_key = std::env::var("NEXTBLOCK_API_KEY")
        .context("set NEXTBLOCK_API_KEY before running")?;

    let client = NextblockQuicClient::connect("london.nextblock.io:11100", &api_key).await?;

    // Replace this with the signed bytes from your existing Solana flow.
    // For example: let raw_tx = bincode::serialize(&signed_transaction)?;
    let raw_tx: Vec<u8> = vec![0; 32];

    client.send_transaction(&raw_tx).await?;
    println!("transaction queued");

    Ok(())
}

What To Replace

  • Swap london.nextblock.io:11100 for the region closest to you.

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

  • Read your API key from NEXTBLOCK_API_KEY or your own config loader.

Notes

  • Keep the client alive and reuse it for many sends.

  • The server expects raw transaction bytes, not base64.

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

  • If you already build transactions with solana-sdk, serializing the signed transaction is enough before calling send_transaction().

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

Last updated