Compare commits

..

4 Commits

Author SHA1 Message Date
Wood 19f11aa620 Release v4.0.2 with Astralane QUIC reliability updates.
Bump crate/docs version to 4.0.2 and add release notes covering IPv4/IPv6 address-family handling, direct-IP candidate preference, and endpoint failover rotation for more stable low-latency QUIC submission.

Made-with: Cursor
2026-03-24 18:48:44 +08:00
Wood 61cea7546c Fix Astralane QUIC address-family mismatch and add endpoint failover.
Prefer IPv4 candidates (including official Astralane direct IPs), bind local QUIC socket by remote address family, and rotate reconnect attempts across candidates for higher reliability under DNS/Cloudflare variance.

Made-with: Cursor
2026-03-24 18:28:41 +08:00
Wood d52503b8de Release v4.0.1: Fix BlockRazor gRPC endpoints and set gRPC as default transport
## Changes since v4.0.0

### Bug Fixes
- Fixed BlockRazor gRPC endpoints to use correct gRPC service URLs
- Set gRPC as the default transport layer for BlockRazor with HTTP fallback

This release improves the reliability and performance of BlockRazor integration by prioritizing gRPC communication while maintaining HTTP fallback compatibility.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 01:34:48 +08:00
Wood 9e53b7694c Fix BlockRazor gRPC endpoints and set gRPC as default transport
- Add SWQOS_ENDPOINTS_BLOCKRAZOR_GRPC with correct gRPC endpoints
- Add Grpc variant to SwqosTransport enum
- Add get_endpoint_with_transport() method for transport-aware endpoint selection
- Change BlockRazor default: transport=None now uses gRPC (was HTTP)
- Require explicit Some(SwqosTransport::Http) for HTTP fallback

This fixes the issue where BlockRazor was incorrectly using HTTP endpoints
when gRPC was intended. Users can now use gRPC by default or explicitly
specify HTTP transport when needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 01:17:06 +08:00
7 changed files with 242 additions and 52 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "sol-trade-sdk"
version = "4.0.0"
version = "4.0.2"
edition = "2021"
authors = [
"William <byteblock6@gmail.com>",
+2 -2
View File
@@ -90,14 +90,14 @@ Add the dependency to your `Cargo.toml`:
```toml
# Add to your Cargo.toml
sol-trade-sdk = { path = "./sol-trade-sdk", version = "4.0.0" }
sol-trade-sdk = { path = "./sol-trade-sdk", version = "4.0.2" }
```
### Use crates.io
```toml
# Add to your Cargo.toml
sol-trade-sdk = "4.0.0"
sol-trade-sdk = "4.0.2"
```
## 🛠️ Usage Examples
+2 -2
View File
@@ -90,14 +90,14 @@ git clone https://github.com/0xfnzero/sol-trade-sdk
```toml
# 添加到您的 Cargo.toml
sol-trade-sdk = { path = "./sol-trade-sdk", version = "4.0.0" }
sol-trade-sdk = { path = "./sol-trade-sdk", version = "4.0.2" }
```
### 使用 crates.io
```toml
# 添加到您的 Cargo.toml
sol-trade-sdk = "4.0.0"
sol-trade-sdk = "4.0.2"
```
## 🛠️ 使用示例
+19
View File
@@ -0,0 +1,19 @@
## sol-trade-sdk v4.0.2
This release focuses on QUIC reliability and low-latency submission stability for Astralane.
### Highlights
- Fixed Astralane QUIC address-family mismatch that could produce `invalid remote address` when DNS returned IPv6 first and local endpoint was IPv4-only.
- Added remote-family-aware local QUIC bind selection:
- IPv4 remote -> bind `0.0.0.0:0`
- IPv6 remote -> bind `[::]:0`
- Added Astralane direct-IP candidate support (official region IPs), with IPv4-first selection for better QUIC stability.
- Added automatic endpoint failover and reconnect rotation across candidate addresses, reducing single-endpoint/DNS variance impact.
- Kept existing SDK interfaces compatible while improving submit-path resiliency.
### Also included from recent updates
- BlockRazor gRPC endpoint fixes and gRPC default transport behavior improvements (v4.0.1).
- SWQOS transport path hardening and Binary-Tx response handling improvements (v4.0.0).
+13
View File
@@ -277,6 +277,19 @@ pub const SWQOS_ENDPOINTS_BLOCKRAZOR: [&str; 8] = [
"http://frankfurt.solana.blockrazor.xyz:443/v2/sendTransaction",
];
/// BlockRazor gRPC endpoints. Region order: NewYork, Frankfurt, Amsterdam, SLC, Tokyo, London, LosAngeles, Default.
/// Port 80 for gRPC protocol. Auth: apikey metadata in gRPC headers.
pub const SWQOS_ENDPOINTS_BLOCKRAZOR_GRPC: [&str; 8] = [
"http://newyork.solana-grpc.blockrazor.xyz:80",
"http://frankfurt.solana-grpc.blockrazor.xyz:80",
"http://amsterdam.solana-grpc.blockrazor.xyz:80",
"http://newyork.solana-grpc.blockrazor.xyz:80",
"http://tokyo.solana-grpc.blockrazor.xyz:80",
"http://london.solana-grpc.blockrazor.xyz:80",
"http://newyork.solana-grpc.blockrazor.xyz:80",
"http://frankfurt.solana-grpc.blockrazor.xyz:80",
];
/// Astralane binary API path (no Base64; use with ?api-key=...&method=sendTransaction|getHealth).
pub const ASTRALANE_PATH_IRISB: &str = "irisb";
+146 -30
View File
@@ -6,8 +6,9 @@ use quinn::crypto::rustls::QuicClientConfig;
use quinn::{ClientConfig, Connection, Endpoint, IdleTimeout, TransportConfig};
use rcgen::{CertificateParams, KeyPair};
use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
use std::net::SocketAddr;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::str::FromStr;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex;
@@ -40,49 +41,171 @@ pub struct AstralaneQuicClient {
endpoint: Endpoint,
connection: Mutex<Connection>,
server_addr: SocketAddr,
server_candidates: Vec<SocketAddr>,
next_server_idx: AtomicUsize,
#[allow(dead_code)]
api_key: String,
}
impl AstralaneQuicClient {
#[inline]
fn astralane_quic_ip_candidates(host: &str, port: u16) -> Vec<SocketAddr> {
// Official recommended direct-IP list (faster/more stable than DNS-only for QUIC).
// We intentionally avoid fr2/ams2 per prior guidance.
match host {
"fr.gateway.astralane.io" => vec![
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(185, 191, 117, 97)), 7000),
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(45, 139, 132, 160)), 7000),
],
"ny.gateway.astralane.io" => {
vec![SocketAddr::new(IpAddr::V4(Ipv4Addr::new(64, 130, 45, 19)), 7000)]
}
"ams.gateway.astralane.io" => vec![
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(64, 130, 43, 43)), 7000),
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(84, 32, 186, 73)), 7000),
],
"la.gateway.astralane.io" => {
vec![SocketAddr::new(IpAddr::V4(Ipv4Addr::new(74, 118, 142, 151)), 7000)]
}
"lim.gateway.astralane.io" => {
vec![SocketAddr::new(IpAddr::V4(Ipv4Addr::new(162, 19, 222, 232)), 7000)]
}
"sg.gateway.astralane.io" => {
vec![SocketAddr::new(IpAddr::V4(Ipv4Addr::new(67, 209, 54, 176)), 7000)]
}
"lit.gateway.astralane.io" => {
vec![SocketAddr::new(IpAddr::V4(Ipv4Addr::new(84, 32, 97, 47)), 7000)]
}
_ => {
let _ = port; // keep signature consistent; fall back to DNS below.
Vec::new()
}
}
}
#[inline]
fn parse_host_port(server_addr: &str) -> Option<(&str, u16)> {
let (host, port_str) = server_addr.rsplit_once(':')?;
let port = port_str.parse::<u16>().ok()?;
Some((host, port))
}
/// Resolve `host:port` and prefer IPv4 result to avoid v6-remote/v4-local mismatch.
#[inline]
fn resolve_server_candidates(server_addr: &str) -> Result<Vec<SocketAddr>> {
if let Ok(addr) = SocketAddr::from_str(server_addr) {
return Ok(vec![addr]);
}
let mut candidates: Vec<SocketAddr> = Vec::with_capacity(8);
if let Some((host, port)) = Self::parse_host_port(server_addr) {
candidates.extend(Self::astralane_quic_ip_candidates(host, port));
}
use std::net::ToSocketAddrs;
let mut addrs: Vec<SocketAddr> = server_addr
.to_socket_addrs()
.with_context(|| format!("Cannot resolve address: {}", server_addr))?
.collect();
if addrs.is_empty() && candidates.is_empty() {
anyhow::bail!("Cannot resolve address: {}", server_addr);
}
// QUIC in many bot/VPS environments is primarily IPv4; prefer A over AAAA.
addrs.sort_by_key(|a| if a.is_ipv4() { 0 } else { 1 });
for addr in addrs {
if !candidates.contains(&addr) {
candidates.push(addr);
}
}
Ok(candidates)
}
#[inline]
fn local_bind_for_remote(remote: SocketAddr) -> SocketAddr {
match remote.ip() {
IpAddr::V4(_) => SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0),
IpAddr::V6(_) => SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0),
}
}
/// Connect to an Astralane QUIC server.
/// Generates a self-signed TLS certificate with the API key as the Common Name (CN).
pub async fn connect(server_addr: &str, api_key: &str) -> Result<Self> {
let _ = rustls::crypto::ring::default_provider().install_default();
let addr = SocketAddr::from_str(server_addr)
.or_else(|_| {
use std::net::ToSocketAddrs;
server_addr
.to_socket_addrs()
.ok()
.and_then(|mut addrs| addrs.next())
.ok_or_else(|| anyhow::anyhow!("Cannot resolve address: {}", server_addr))
})
let candidates = Self::resolve_server_candidates(server_addr)
.context("Invalid server address")?;
let addr = candidates[0];
info!("[astralane-quic] Building TLS config (CN = api_key)");
let client_config = Self::build_client_config(api_key)?;
let mut endpoint =
Endpoint::client("0.0.0.0:0".parse()?).context("Failed to create QUIC endpoint")?;
let mut endpoint = Endpoint::client(Self::local_bind_for_remote(addr))
.context("Failed to create QUIC endpoint")?;
endpoint.set_default_client_config(client_config);
info!("[astralane-quic] Connecting to {} ...", addr);
let connection = endpoint
.connect(addr, "astralane")?
.await
.context("Failed to connect to Astralane QUIC server")?;
let mut last_err: Option<anyhow::Error> = None;
let mut selected_addr = addr;
let mut connection_opt: Option<Connection> = None;
for candidate in &candidates {
selected_addr = *candidate;
match endpoint.connect(*candidate, "astralane") {
Ok(connecting) => match connecting.await {
Ok(conn) => {
connection_opt = Some(conn);
break;
}
Err(e) => {
warn!("[astralane-quic] connect failed for {}: {}", candidate, e);
last_err = Some(e.into());
}
},
Err(e) => {
warn!("[astralane-quic] connect setup failed for {}: {}", candidate, e);
last_err = Some(e.into());
}
}
}
let connection = connection_opt.ok_or_else(|| {
last_err.unwrap_or_else(|| anyhow::anyhow!("Failed to connect to Astralane QUIC server"))
})?;
info!("[astralane-quic] Connected at {}", addr);
info!("[astralane-quic] Connected at {}", selected_addr);
Ok(Self {
endpoint,
connection: Mutex::new(connection),
server_addr: addr,
server_addr: selected_addr,
server_candidates: candidates,
next_server_idx: AtomicUsize::new(0),
api_key: api_key.to_string(),
})
}
async fn reconnect_next_candidate(&self) -> Result<Connection> {
let total = self.server_candidates.len().max(1);
let start = self.next_server_idx.fetch_add(1, Ordering::Relaxed) % total;
let mut last_err: Option<anyhow::Error> = None;
for offset in 0..total {
let idx = (start + offset) % total;
let addr = self.server_candidates[idx];
match self.endpoint.connect(addr, "astralane") {
Ok(connecting) => match connecting.await {
Ok(conn) => return Ok(conn),
Err(e) => {
warn!("[astralane-quic] reconnect failed for {}: {}", addr, e);
last_err = Some(e.into());
}
},
Err(e) => {
warn!("[astralane-quic] reconnect setup failed for {}: {}", addr, e);
last_err = Some(e.into());
}
}
}
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("Failed to reconnect to Astralane QUIC server")))
}
/// Send a single bincode-serialized `VersionedTransaction`.
/// Fire-and-forget; automatically reconnects if the connection is dead.
pub async fn send_transaction(&self, transaction_bytes: &[u8]) -> Result<()> {
@@ -108,12 +231,9 @@ impl AstralaneQuicClient {
}
}
warn!("[astralane-quic] Connection dead, reconnecting to {} ...", self.server_addr);
*guard = self
.endpoint
.connect(self.server_addr, "astralane")?
.await
.context("Failed to reconnect to Astralane QUIC server")?;
info!("[astralane-quic] Reconnected to {}", self.server_addr);
let new_conn = self.reconnect_next_candidate().await?;
*guard = new_conn.clone();
info!("[astralane-quic] Reconnected");
}
guard.clone()
};
@@ -137,12 +257,8 @@ impl AstralaneQuicClient {
let mut guard = self.connection.lock().await;
if guard.close_reason().is_some() {
info!("[astralane-quic] Reconnecting at {}", self.server_addr);
*guard = self
.endpoint
.connect(self.server_addr, "astralane")?
.await
.context("Failed to reconnect to Astralane QUIC server")?;
info!("[astralane-quic] Reconnected to {}", self.server_addr);
*guard = self.reconnect_next_candidate().await?;
info!("[astralane-quic] Reconnected");
}
Ok(())
}
+59 -17
View File
@@ -30,15 +30,16 @@ use crate::{
common::SolanaRpcClient,
constants::swqos::{
SWQOS_ENDPOINTS_ASTRALANE, SWQOS_ENDPOINTS_ASTRALANE_QUIC, SWQOS_ENDPOINTS_BLOCKRAZOR,
SWQOS_ENDPOINTS_BLOX, SWQOS_ENDPOINTS_FLASHBLOCK, SWQOS_ENDPOINTS_HELIUS,
SWQOS_ENDPOINTS_JITO, SWQOS_ENDPOINTS_NEXTBLOCK, SWQOS_ENDPOINTS_NODE1,
SWQOS_ENDPOINTS_NODE1_QUIC, SWQOS_ENDPOINTS_SOYAS, SWQOS_ENDPOINTS_SPEEDLANDING,
SWQOS_ENDPOINTS_STELLIUM, SWQOS_ENDPOINTS_TEMPORAL, SWQOS_ENDPOINTS_ZERO_SLOT,
SWQOS_MIN_TIP_ASTRALANE, SWQOS_MIN_TIP_BLOCKRAZOR, SWQOS_MIN_TIP_BLOXROUTE,
SWQOS_MIN_TIP_DEFAULT, SWQOS_MIN_TIP_FLASHBLOCK, SWQOS_MIN_TIP_HELIUS, SWQOS_MIN_TIP_JITO,
SWQOS_MIN_TIP_LIGHTSPEED, SWQOS_MIN_TIP_NEXTBLOCK, SWQOS_MIN_TIP_NODE1,
SWQOS_MIN_TIP_SOYAS, SWQOS_MIN_TIP_SPEEDLANDING, SWQOS_MIN_TIP_STELLIUM,
SWQOS_MIN_TIP_TEMPORAL, SWQOS_MIN_TIP_ZERO_SLOT,
SWQOS_ENDPOINTS_BLOCKRAZOR_GRPC, SWQOS_ENDPOINTS_BLOX, SWQOS_ENDPOINTS_FLASHBLOCK,
SWQOS_ENDPOINTS_HELIUS, SWQOS_ENDPOINTS_JITO, SWQOS_ENDPOINTS_NEXTBLOCK,
SWQOS_ENDPOINTS_NODE1, SWQOS_ENDPOINTS_NODE1_QUIC, SWQOS_ENDPOINTS_SOYAS,
SWQOS_ENDPOINTS_SPEEDLANDING, SWQOS_ENDPOINTS_STELLIUM, SWQOS_ENDPOINTS_TEMPORAL,
SWQOS_ENDPOINTS_ZERO_SLOT, SWQOS_MIN_TIP_ASTRALANE, SWQOS_MIN_TIP_BLOCKRAZOR,
SWQOS_MIN_TIP_BLOXROUTE, SWQOS_MIN_TIP_DEFAULT, SWQOS_MIN_TIP_FLASHBLOCK,
SWQOS_MIN_TIP_HELIUS, SWQOS_MIN_TIP_JITO, SWQOS_MIN_TIP_LIGHTSPEED,
SWQOS_MIN_TIP_NEXTBLOCK, SWQOS_MIN_TIP_NODE1, SWQOS_MIN_TIP_SOYAS,
SWQOS_MIN_TIP_SPEEDLANDING, SWQOS_MIN_TIP_STELLIUM, SWQOS_MIN_TIP_TEMPORAL,
SWQOS_MIN_TIP_ZERO_SLOT,
},
swqos::{
astralane::AstralaneClient, blockrazor::BlockRazorClient, bloxroute::BloxrouteClient,
@@ -63,11 +64,14 @@ pub const SWQOS_BLACKLIST: &[SwqosType] = &[
SwqosType::NextBlock, // NextBlock is disabled by default
];
/// SWQOS 提交通道:HTTP 或 QUIC(低延迟)。部分提供商(如 Astralane)支持 QUIC。
/// SWQOS 提交通道:HTTP、gRPC 或 QUIC(低延迟)。
/// BlockRazor 支持 gRPC 和 HTTP。
/// Astralane 和 Node1 支持 QUIC。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum SwqosTransport {
#[default]
Http,
Grpc,
Quic,
}
@@ -224,7 +228,7 @@ pub enum SwqosConfig {
Node1(String, SwqosRegion, Option<String>, Option<SwqosTransport>),
/// FlashBlock(api_token, region, custom_url)
FlashBlock(String, SwqosRegion, Option<String>),
/// BlockRazor(api_token, region, custom_url, transport). transport=None => gRPC; Some(Http) => HTTP.
/// BlockRazor(api_token, region, custom_url, transport). transport=None 或 Grpc => gRPC; Some(Http) => HTTP.
BlockRazor(String, SwqosRegion, Option<String>, Option<SwqosTransport>),
/// Astralane(api_token, region, custom_url, transport). transport=None 表示 Http。
Astralane(String, SwqosRegion, Option<String>, Option<SwqosTransport>),
@@ -294,6 +298,46 @@ impl SwqosConfig {
}
}
pub fn get_endpoint_with_transport(
swqos_type: SwqosType,
region: SwqosRegion,
url: Option<String>,
transport: Option<SwqosTransport>,
) -> String {
if let Some(custom_url) = url {
return custom_url;
}
match swqos_type {
SwqosType::BlockRazor => {
// transport=None 或 transport=Grpc => gRPC; transport=Http => HTTP
let use_http = transport.map_or(false, |t| t == SwqosTransport::Http);
if use_http {
SWQOS_ENDPOINTS_BLOCKRAZOR[region as usize].to_string()
} else {
SWQOS_ENDPOINTS_BLOCKRAZOR_GRPC[region as usize].to_string()
}
}
SwqosType::Node1 => {
let use_quic = transport.map_or(false, |t| t == SwqosTransport::Quic);
if use_quic {
SWQOS_ENDPOINTS_NODE1_QUIC[region as usize].to_string()
} else {
SWQOS_ENDPOINTS_NODE1[region as usize].to_string()
}
}
SwqosType::Astralane => {
let use_quic = transport.map_or(false, |t| t == SwqosTransport::Quic);
if use_quic {
SWQOS_ENDPOINTS_ASTRALANE_QUIC[region as usize].to_string()
} else {
SWQOS_ENDPOINTS_ASTRALANE[region as usize].to_string()
}
}
_ => Self::get_endpoint(swqos_type, region, url),
}
}
pub async fn get_swqos_client(
rpc_url: String,
commitment: CommitmentConfig,
@@ -352,17 +396,15 @@ impl SwqosConfig {
Ok(Arc::new(flashblock_client))
}
SwqosConfig::BlockRazor(auth_token, region, url, transport) => {
// BlockRazor: transport=None 或 transport=Http 时使用 HTTP,否则使用 gRPC
// 默认使用 HTTP,避免 gRPC FRAME_SIZE_ERROR
let use_http = transport.map_or(true, |t| t == SwqosTransport::Http);
// BlockRazor: transport=None 或 transport=Grpc 时使用 gRPCtransport=Http 时使用 HTTP
let use_http = transport.map_or(false, |t| t == SwqosTransport::Http);
let endpoint = SwqosConfig::get_endpoint_with_transport(SwqosType::BlockRazor, region, url, transport);
if use_http {
let endpoint = SwqosConfig::get_endpoint(SwqosType::BlockRazor, region, url);
let blockrazor_client =
BlockRazorClient::new_http(rpc_url.clone(), endpoint.to_string(), auth_token);
Ok(Arc::new(blockrazor_client))
} else {
// 使用 gRPC 模式(用户明确指定了非 Http 的 transport
let endpoint = SwqosConfig::get_endpoint(SwqosType::BlockRazor, region, url);
// 使用 gRPC 模式(默认或用户明确指定了 gRPC
let blockrazor_client =
BlockRazorClient::new_grpc(rpc_url.clone(), endpoint.to_string(), auth_token).await?;
Ok(Arc::new(blockrazor_client))