大多数 Solana 的 Rust 示例教你怎么发一笔交易。生产级发送器在四个具体位置上不一样,而这四处的默认值全是错的。
用 nonblocking 客户端
solana_client::rpc_client::RpcClient 是同步的。它会阻塞所在的线程 —— 写脚本没问题,但任何并发发送的场景都不该用。
use solana_client::nonblocking::rpc_client::RpcClient;
use solana_sdk::commitment_config::CommitmentConfig;
let client = RpcClient::new_with_commitment(
rpc_url.to_string(),
CommitmentConfig::confirmed(),
);
★nonblocking 客户端内部共享一个 HTTP 客户端,所以连接复用是白送的。★ 构造一个然后用 Arc 共享,而不是每个任务建一个 —— 每次发送新建一个客户端,就是每次发送做一次 TLS 握手。
use std::sync::Arc;
let client = Arc::new(RpcClient::new(rpc_url.to_string()));
// 每个任务克隆的是 Arc,不是客户端本身。
let c = Arc::clone(&client);
tokio::spawn(async move { submit(c, tx).await });
真正有影响的 send config
RpcSendTransactionConfig 的默认值是为正确性调的,不是为交易调的:
use solana_client::rpc_config::RpcSendTransactionConfig;
use solana_sdk::commitment_config::CommitmentLevel;
let config = RpcSendTransactionConfig {
skip_preflight: true, // ★默认是 false★
preflight_commitment: Some(CommitmentLevel::Processed),
max_retries: Some(0), // ★默认是 None★
..Default::default()
};
let sig = client.send_transaction_with_config(&tx, config).await?;
skip_preflight: true。 preflight 要花一次往返,而且模拟的是当前 slot —— 那不是你会落进去的那个。真正有用的模拟放在开发阶段。
max_retries: Some(0)。 None 会让 RPC 按自己的节奏重试。你的循环也在重试的话,两个组件按不同时钟重发,行为变得无法复现。★只有你的循环知道 blockhash 什么时候过期。★
如果你的 Rust 发送器已经调好、交易却仍然抢不到,免费领个 BoltTx key 改一行配置就能拿来对比。
缓存 blockhash
在热路径上取 blockhash 是一次你付不起的往返。放到后台去刷新:
use tokio::sync::RwLock;
use solana_sdk::hash::Hash;
#[derive(Clone)]
struct BlockhashCache {
inner: Arc<RwLock<(Hash, u64)>>, // (blockhash, last_valid_block_height)
}
impl BlockhashCache {
async fn spawn(client: Arc<RpcClient>) -> Self {
let initial = client
.get_latest_blockhash_with_commitment(CommitmentConfig::confirmed())
.await
.expect("initial blockhash");
let cache = Self { inner: Arc::new(RwLock::new(initial)) };
let bg = cache.clone();
tokio::spawn(async move {
let mut tick = tokio::time::interval(Duration::from_secs(5));
loop {
tick.tick().await;
if let Ok(v) = client
.get_latest_blockhash_with_commitment(CommitmentConfig::confirmed())
.await
{
*bg.inner.write().await = v;
}
}
});
cache
}
async fn get(&self) -> (Hash, u64) {
*self.inner.read().await
}
}
★用 RwLock 而不是 Mutex★ —— 读远多于写,而每一次发送都是一次读。
重试循环
真正能让交易落块的写法:
use solana_sdk::signature::Signature;
use std::time::Duration;
async fn submit_until_landed(
client: &RpcClient,
tx: &impl solana_sdk::transaction::SerializableTransaction,
last_valid_block_height: u64,
) -> anyhow::Result<Option<Signature>> {
let config = RpcSendTransactionConfig {
skip_preflight: true,
max_retries: Some(0),
..Default::default()
};
let sig = client.send_transaction_with_config(tx, config).await?;
loop {
let height = client.get_block_height().await?;
if height > last_valid_block_height {
return Ok(None); // ★已过期:重建,不要重发★
}
let statuses = client.get_signature_statuses(&[sig]).await?;
if statuses.value[0].is_some() {
return Ok(Some(sig)); // 已上链;看 .err 判断结果
}
// 同样的字节、同样的签名 —— 最多被收录一次。
let _ = client.send_transaction_with_config(tx, config).await;
tokio::time::sleep(Duration::from_millis(400)).await; // 约一个 slot
}
}
★这里不要退避。★ 每一次尝试都是面对一个新出块方的新机会,而有效期窗口很短。退避意味着在一个你无法延长的窗口里尝试更少次。
计算预算和费用
指令放最前面,而且值该算出来、不该写死:
use solana_sdk::compute_budget::ComputeBudgetInstruction;
// 费用是按账户竞争的,不是全局的。
let recent = client
.get_recent_prioritization_fees(&writable_accounts)
.await?;
let mut fees: Vec<u64> = recent.iter().map(|f| f.prioritization_fee).collect();
fees.sort_unstable();
let median = fees.get(fees.len() / 2).copied().unwrap_or(0);
let mut instructions = vec![
ComputeBudgetInstruction::set_compute_unit_limit(measured_units * 12 / 10),
ComputeBudgetInstruction::set_compute_unit_price((median * 2).max(5_000)),
];
instructions.extend(your_instructions);
★measured_units 该来自模拟,不是猜的。★ 把 limit 留在默认值,意味着按一个远高于真实用量的数字计费 —— 而这恰好浪费在费用高的时候。
为什么用 Rust,以及为什么不
既然这是争论最多的决策,说个实在的版本:
★原始执行速度很少是差异所在。★ 签名、序列化、构造指令是微秒级的工作,而网络时间是毫秒级的。把一个能跑的 TypeScript 机器人重写成 Rust,本身不会改变你落在哪个 slot。
Rust 真正给你的:
没有 GC 停顿。 Node.js 的一次 major GC 偶尔会超过一个 slot,而且它发生在高负载、队列最深的时候。
更紧的时间分布。 对一个活在尾部而不是中位数的策略来说,这种一致性才是真正的论据。
更底层的连接控制。 更容易保证热路径上没有握手。
★如果你现有的机器人已经稳定在两个 slot 内落块,重写不会提升成交率。★ 承诺重写之前先测 slot 分布。
能区分情况的错误处理
ClientError 涵盖了从网络超时到程序失败的一切。一视同仁地处理它们,正是机器人反复重试不可重试的失败的原因:
use solana_client::client_error::ClientErrorKind;
match client.send_transaction_with_config(&tx, config).await {
Ok(sig) => { /* 已提交;接下来确认 */ }
Err(e) => match e.kind() {
// 传输问题 —— 带退避重试。
ClientErrorKind::Reqwest(_) => { /* 退避、重试 */ }
// ★RPC 拒绝了它 —— 重发相同字节没用。★
ClientErrorKind::RpcError(_) => { /* 检查,多半要重建 */ }
_ => { /* 记日志并抛出 */ }
},
}
★传输错误可能意味着交易其实提交成功了,只是响应丢了。★ 假设它失败之前,先查签名状态。
上链应该是什么水平
我们投递节点上的真实交易:中位确认 336 毫秒 —— 不到一个 slot。
★如果你的 Rust 发送器按上面调好了、分布仍然明显更宽,那剩下的就是路由★ —— 客户端任何改动都够不到的那一段。
BoltTx 在这里的位置
我们做你的客户端之后的那一跳。不做索引,不做流式推送。
提交走我们自建的四区域投递节点,带 SWQoS 路由,不暴露公共 mempool —— 交易在落块之前,传输途中观察不到。你在本地签名,我们不托管资金、不代签、不修改交易内容。
tip 带在交易里、从你自己的钱包链上支付,交易失败时跟着一起 revert —— 这是 Solana 原子交易的机制决定的。只有到达链上的交易才计费。
免费领 API key,没有月费:
let client = RpcClient::new(
"https://la.bolttx.io/?api-key=YOUR_KEY".to_string()
);
常见问题
该用 RpcClient 还是 nonblocking 版本? 任何并发场景用 nonblocking 那个。同步客户端会阻塞它所在的线程 —— 写脚本没问题,但对一个同时发好几笔交易的机器人是错的。
solana-client 里怎么复用连接?
构造一个客户端,用 Arc 共享。nonblocking 客户端内部持有一个连接池化的 HTTP 客户端,所以共享实例就能复用连接。每次发送建一个客户端 = 每次发送一次 TLS 握手。
Rust 机器人该用什么 send config?
skip_preflight: true 和 max_retries: Some(0)。前者去掉一次"模拟错误 slot"的往返;后者阻止 RPC 按你的循环看不到的节奏去重试。
Rust 里为什么要把 max_retries 设成 0?
因为 None 会让 RPC 独立重试。你的循环也在重发的话,两个组件按不同时钟跑,失败无法复现。只有你的循环知道过期高度。
Rust 里怎么缓存 blockhash?
用 RwLock 存,并在后台的 tokio::time::interval 里刷新。读远多于写,所以 RwLock 优于 Mutex,而且热路径上永远不发网络请求。
Solana 机器人用 Rust 比 TypeScript 快吗? 纯执行是的,但那部分相对网络时间可以忽略。真正的优势是没有 GC 停顿、以及更紧的时间分布 —— 对尾部敏感的策略才重要。
该把 TypeScript 机器人重写成 Rust 吗? 先测。如果它已经稳定在两个 slot 内落块,重写不会提升成交率。如果你看到偶发的多 slot 离群值、而且和负载相关,那可能是 GC,Rust 会有帮助。
ClientError 该怎么正确处理?
按 ClientErrorKind 分支。传输错误可以带退避重试,但要先查签名状态 —— 交易可能已经提交成功、只是响应丢了。RPC 拒绝通常需要重建。
Rust 里怎么设置 compute budget 指令?
ComputeBudgetInstruction::set_compute_unit_limit 和 set_compute_unit_price,放在你的指令之前。limit 从模拟里来,price 从你可写账户的近期费用来。
Rust 发送器的重试间隔该设多少? 大约一个 slot。每次重发都是面对新出块方的新机会,而有效期窗口很短。这里不要加退避 —— 退避是给限流用的,不是给"被收录"用的。
Rust 里重发同一笔交易安全吗? 安全。相同的签名字节产生相同签名,Solana 对任何签名最多收录一次。持续重发到过期是标准做法。
怎么知道什么时候该停止重试?
拿 get_block_height() 和 blockhash 一起返回的 last_valid_block_height 比。越过去了,交易就永久无效,必须重建而不是重发。
solana-client 支持版本化交易吗?
支持。构造 VersionedTransaction 用同样的方法发送。读回它们时要设置最大支持版本,和 JavaScript 客户端一样。
该给每笔交易 spawn 一个任务吗?
一般来说该,配合共享的 Arc<RpcClient>。不该做的是每个任务建一个客户端 —— 那会丢掉连接复用,给每次发送加一次握手。
Rust 里怎么测 slot 距离?
提交前记 get_slot(),从 get_signature_statuses 读落块的 slot。当成分布来跟踪而不是平均值,并按网络状况分桶。
为什么我的 Rust 机器人和 TypeScript 那个表现一样? 因为两者都受限于网络时间而不是计算。这是预期结果,它意味着你的优化精力该花在路由、费用和重试行为上,而不是语言上。