Infrastructure

Fixing QUIC ALPN Error 120 on AllenHark Relay: A Complete Connection Guide

May 15, 2026AllenHark Team

If you have ever tried to wire a homemade QUIC client into AllenHark Relay and been greeted by this exact log line:

Allenhark QUIC relay connect failed: aborted by peer: the cryptographic
handshake failed: error 120: peer doesn't support any known protocol

you are not alone. This single error is, by a wide margin, the most common integration failure we see — and it has almost nothing to do with AllenHark, your API key, your firewall, or your TLS roots. It is an ALPN negotiation mismatch, and once you understand what is happening at the TLS layer the fix is a one-line change.

This guide walks through the cause, then gives you copy-paste-ready Rust (Quinn) and Python (aioquic) clients that connect cleanly to relay.allenhark.com:4433, send a base64-encoded transaction, and read back the relay's JSON response.


What error 120 actually means

TLS alert code 120 is no_application_protocol, defined in RFC 7301. It is sent by the server to the client during the TLS handshake when:

  1. The client offered an ALPN (Application-Layer Protocol Negotiation) extension listing one or more protocol identifiers it wants to speak, and
  2. The server's ALPN list does not intersect with the client's list (or the server has no ALPN configured but the client insisted on one).

In other words: the client said "I will only talk solana-tpu" (or h3, or lunar-lander-tpu, or whatever) and the server replied "I do not speak any of those — goodbye." Quinn surfaces this as ConnectionError::TransportError with an inner crypto_error of 120 and the human-readable string peer doesn't support any known protocol.

Why this hits AllenHark specifically

AllenHark Relay's client-facing QUIC endpoint at relay.allenhark.com:4433 is not a Solana validator TPU port. It is a relay protocol of our own — a single bidirectional stream carrying an api-key: header line, a JSON payload, and a JSON response. It does not negotiate any application protocol over ALPN, which means the server-side ALPN list is intentionally empty.

The Rust server initialization (in AllenHarkRelay/src/main.rs) looks roughly like this:

let mut server_config = quinn::ServerConfig::with_single_cert(certs, key)?;
server_config.transport_config(Arc::new(transport_config));
let endpoint = quinn::Endpoint::server(server_config, addr)?;

Notice what is not there: any call to set alpn_protocols. The relay deliberately accepts QUIC connections without an application protocol identifier.

So when your client does this:

let mut crypto = rustls::ClientConfig::builder()
    .with_root_certificates(roots)
    .with_no_client_auth();

crypto.alpn_protocols = vec![b"solana-tpu".to_vec()]; // <-- DANGER

…the TLS server reads the client's offered ALPN list, finds nothing it can agree to, and immediately fires alert 120. Your QUIC handshake dies before a single byte of the application payload is exchanged.

How the bug usually gets in your code

Nine times out of ten, the offending line was copy-pasted from a Solana validator TPU client. Agave, Jito, and most TPU QUIC examples set:

const ALPN_TPU_PROTOCOL: &[&[u8]] = &[b"solana-tpu"];
crypto.alpn_protocols = ALPN_TPU_PROTOCOL.iter().map(|p| p.to_vec()).collect();

That is correct when you are sending directly to a validator's TPU port. AllenHark Relay is upstream of the TPU — we open our own QUIC connections to validators using solana-tpu ALPN, but the client-facing endpoint you are connecting to does not use that protocol.

The fix is to remove the ALPN line entirely (or set it to an empty Vec). That is it. That is the whole bug.


The minimum-viable connection (Rust + Quinn)

Here is a complete, working Rust client. The Cargo.toml dependencies you need:

[dependencies]
anyhow = "1"
base64 = "0.22"
quinn = "0.11"
rustls = { version = "0.23", features = ["ring"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
webpki-roots = "0.26"

And the client:

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

use anyhow::{Context, Result};
use base64::Engine;
use quinn::crypto::rustls::QuicClientConfig;
use rustls::RootCertStore;

const RELAY_ADDR: &str = "relay.allenhark.com:4433";
const RELAY_SNI: &str = "relay.allenhark.com";

pub struct AllenharkQuic {
    endpoint: quinn::Endpoint,
    conn: tokio::sync::Mutex<Option<quinn::Connection>>,
    api_key: String,
}

impl AllenharkQuic {
    pub fn new(api_key: impl Into<String>) -> Result<Self> {
        // Install the default crypto provider once per process. If you
        // already do this elsewhere, ignore the Result.
        let _ = rustls::crypto::ring::default_provider().install_default();

        let mut roots = RootCertStore::empty();
        roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());

        let mut crypto = rustls::ClientConfig::builder()
            .with_root_certificates(roots)
            .with_no_client_auth();

        // ── CRITICAL ────────────────────────────────────────────────
        // Leave `alpn_protocols` EMPTY. AllenHark Relay does not
        // negotiate an application protocol; offering `solana-tpu`,
        // `h3`, or anything else causes TLS alert 120.
        crypto.alpn_protocols = Vec::new();
        // ────────────────────────────────────────────────────────────

        let client_crypto = QuicClientConfig::try_from(crypto)
            .context("rustls -> QuicClientConfig")?;
        let mut client_cfg = quinn::ClientConfig::new(Arc::new(client_crypto));

        let mut transport = quinn::TransportConfig::default();
        transport.keep_alive_interval(Some(Duration::from_secs(15)));
        transport.max_idle_timeout(Some(Duration::from_secs(60).try_into()?));
        client_cfg.transport_config(Arc::new(transport));

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

        Ok(Self {
            endpoint,
            conn: tokio::sync::Mutex::new(None),
            api_key: api_key.into(),
        })
    }

    async fn connection(&self) -> Result<quinn::Connection> {
        let mut guard = self.conn.lock().await;
        if let Some(c) = guard.as_ref() {
            if c.close_reason().is_none() {
                return Ok(c.clone());
            }
        }
        let addr = RELAY_ADDR
            .to_socket_addrs()?
            .next()
            .context("resolve relay")?;
        let new = self.endpoint.connect(addr, RELAY_SNI)?.await?;
        *guard = Some(new.clone());
        Ok(new)
    }

    /// Send a base64-encoded, fully signed Solana transaction.
    pub async fn send(&self, tx_base64: &str) -> Result<serde_json::Value> {
        let conn = self.connection().await?;
        let (mut send, mut recv) = conn.open_bi().await?;

        // Header line: `api-key: KEY\n`. An empty key is allowed —
        // just send `\n` if you are testing without auth.
        let header = format!("api-key: {}\n", self.api_key);
        send.write_all(header.as_bytes()).await?;

        let body = serde_json::json!({
            "tx": tx_base64,
            "simulate": false,
        });
        send.write_all(&serde_json::to_vec(&body)?).await?;
        send.finish()?;

        let bytes = recv.read_to_end(64 * 1024).await?;
        Ok(serde_json::from_slice(&bytes)?)
    }
}

use std::net::ToSocketAddrs;

#[tokio::main]
async fn main() -> Result<()> {
    let api_key = std::env::var("ALLENHARK_API_KEY").unwrap_or_default();
    let client = AllenharkQuic::new(api_key)?;

    // Replace with a real, signed, recent-blockhash transaction.
    let tx_bytes: Vec<u8> = std::fs::read("signed_tx.bin")?;
    let tx_b64 = base64::engine::general_purpose::STANDARD.encode(&tx_bytes);

    let response = client.send(&tx_b64).await?;
    println!("{}", serde_json::to_string_pretty(&response)?);
    Ok(())
}

What you get back on success

{
  "status": "accepted",
  "request_id": "01HX...",
  "signature": "5g8H...base58..."
}

On rejection:

{
  "status": "rejected",
  "error": "Invalid base64 transaction"
}

Note that status: "accepted" means the relay accepted your transaction for broadcast — it does not mean the transaction has landed in a block. Confirmation is your job; the signature is returned so you can poll for it.


The minimum-viable connection (Python + aioquic)

Python's aioquic is what most users reach for, and it has its own ALPN trap: aioquic requires at least one ALPN protocol if you set any, but its default is an empty list — perfect for AllenHark. Do not borrow ALPN values from HTTP/3 or TPU examples.

Install:

pip install aioquic

Client:

import asyncio
import base64
import json
import os
from typing import Optional

from aioquic.asyncio import connect
from aioquic.asyncio.protocol import QuicConnectionProtocol
from aioquic.quic.configuration import QuicConfiguration
from aioquic.quic.events import StreamDataReceived

RELAY_HOST = "relay.allenhark.com"
RELAY_PORT = 4433


class AllenharkQuicClient:
    def __init__(self, api_key: str = ""):
        self.api_key = api_key

        # ── CRITICAL ──────────────────────────────────────────────
        # `alpn_protocols=None` (the default) means aioquic does NOT
        # send an ALPN extension at all — which is what the relay
        # expects. Do NOT set ["solana-tpu"], ["h3"], or ["hq"]; any
        # of those will trigger TLS alert 120 from the server.
        # ──────────────────────────────────────────────────────────
        self.config = QuicConfiguration(
            is_client=True,
            alpn_protocols=None,
        )
        # The relay uses publicly-trusted certs (Let's Encrypt). Leave
        # verify_mode on the default (CERT_REQUIRED). Only flip it if
        # you are pinning your own cert.

    async def send(self, tx_base64: str, timeout: float = 5.0) -> dict:
        async with connect(
            RELAY_HOST,
            RELAY_PORT,
            configuration=self.config,
        ) as protocol:
            return await asyncio.wait_for(
                self._exchange(protocol, tx_base64),
                timeout=timeout,
            )

    async def _exchange(
        self,
        protocol: QuicConnectionProtocol,
        tx_base64: str,
    ) -> dict:
        # Open a new client-initiated bidirectional stream.
        reader, writer = await protocol.create_stream()

        # Header line first. Empty key is allowed — send `\n` alone.
        header = f"api-key: {self.api_key}\n".encode()
        writer.write(header)

        body = json.dumps({"tx": tx_base64, "simulate": False}).encode()
        writer.write(body)
        writer.write_eof()

        # Drain the entire response stream.
        chunks: list[bytes] = []
        while True:
            chunk = await reader.read(64 * 1024)
            if not chunk:
                break
            chunks.append(chunk)

        raw = b"".join(chunks)
        return json.loads(raw)


async def main():
    api_key = os.environ.get("ALLENHARK_API_KEY", "")
    client = AllenharkQuicClient(api_key)

    with open("signed_tx.bin", "rb") as f:
        tx_b64 = base64.b64encode(f.read()).decode()

    response = await client.send(tx_b64)
    print(json.dumps(response, indent=2))


if __name__ == "__main__":
    asyncio.run(main())

Why create_stream() and not the lower-level API?

You will see a lot of older aioquic snippets that reach into protocol._quic.send_stream_data(...) directly. That works, but it bypasses aioquic's flow control and stream-ID bookkeeping and tends to deadlock on responses larger than one packet. create_stream() returns proper StreamReader / StreamWriter objects that handle backpressure, end-of-stream, and reassembly correctly — which matters here because the relay's response can be split across multiple QUIC packets when broadcasting status is verbose.


The protocol on the wire

Once the TLS handshake clears, the protocol is intentionally trivial. On each bidirectional stream the client writes:

api-key: <YOUR_KEY>\n
{"tx":"<base64-signed-tx>","simulate":false}

…and then closes the send side. The server replies with a single JSON object and closes its send side. One transaction per stream. You are encouraged to keep the connection alive across many transactions — opening a new stream is sub-millisecond, opening a new connection is a full TLS handshake.

A few protocol notes that catch people out:

  • The header is one line, terminated by \n. Sending api-key: KEY without the newline causes the server to keep reading bytes from your JSON body into the header buffer and then reject the request as malformed.
  • An empty key is valid. If you just want to test connectivity, send a single \n as the header line. The relay will accept the connection and broadcast unauthenticated traffic up to the public rate limit.
  • simulate: true is rejected. The relay does not run simulations; that is what your local RPC is for. Always send simulate: false (or omit the field).
  • Tip floor. Like every production relay, AllenHark enforces a minimum tip. If your transaction does not include a transfer to a configured tip account at or above the floor, you will get {"status": "rejected", "error": "Tip below floor"} — and that has nothing to do with ALPN, but it is the second-most-common "why is it failing" complaint we see.

Other failure modes that look like error 120 but aren't

When you are deep in a debugging session at 3am, ALPN errors blur together with everything else. Here are the lookalikes:

What you seeWhat it actually isFix
error 120: peer doesn't support any known protocolALPN mismatchRemove alpn_protocols line
error 40: handshake failureCipher suite or TLS version mismatchUse rustls defaults; don't pin TLS 1.2
error 42: bad certificateYou are using a custom verifier that rejects valid certsUse webpki_roots, not a self-signed verifier
connection timed outUDP egress to port 4433 is blockedTest with nc -u relay.allenhark.com 4433 or open the firewall
0-RTT rejected by serverResumption ticket expired (1h TTL)Falls back to 1-RTT automatically; ignore

If you have triple-checked your ALPN config and you are still getting alert 120, capture a packet trace with tcpdump -i any -w trace.pcap udp port 4433 and open it in Wireshark. The TLS ClientHello will show your ALPN extension; if there is any value listed there, that is the bug. The fix is in your code, not on our side.


Performance after the handshake works

Once your client is connecting cleanly, the next thing to tune is connection reuse. The Rust client above keeps a Mutex<Option<Connection>> and reuses it across sends — that is what you want. A single AllenHark QUIC connection can multiplex hundreds of concurrent bidirectional streams, and stream open is around 50–100 microseconds on a warm connection. By comparison, opening a fresh QUIC connection is 1–2ms on the first attempt and 0.3–0.8ms with 0-RTT resumption.

For a high-frequency arbitrage or sniper workload, the rough hierarchy is:

  1. Best: one long-lived QUIC connection, many streams.
  2. Acceptable: new connection with 0-RTT resumption.
  3. Avoid: new connection per transaction with a cold handshake.

aioquic's connect() context manager is convenient but tears down the connection on __aexit__, so for production Python clients you will want to hold the protocol object across many sends rather than wrapping each call in async with. The pattern is the same as the Rust Mutex<Option<Connection>> — keep one alive, lazily reconnect on close.


Summary

  • TLS alert 120 = no_application_protocol. The server rejected your TLS handshake because it could not find an ALPN it speaks.
  • AllenHark Relay's client-facing QUIC endpoint does not use ALPN. Leave alpn_protocols empty.
  • The protocol is one bidirectional stream per transaction: api-key: KEY\n, then a JSON body {"tx": "...", "simulate": false}, then finish().
  • Reuse connections, not just streams. Open one, keep it alive with a 15s keep-alive, multiplex everything else over it.

If you copy either of the code samples above verbatim and supply a valid base64-encoded signed transaction, you will not see error 120 again. The full integration guide — covering HTTP fallback, rate limits, and tip configuration — lives at Complete Guide to AllenHark Relay: HTTP & QUIC Integration.

Get an AllenHark Relay API key and start submitting in under five minutes.