用 Rust 写一个 Solana 交易发送器

生产环境的 solana-client:nonblocking RpcClient、连接复用、真正有影响的 send config,以及一个到期就停的重试循环。

BoltTx Team··13 min read
solanarustsolana-client交易上链交易机器人异步

大多数 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 那个表现一样? 因为两者都受限于网络时间而不是计算。这是预期结果,它意味着你的优化精力该花在路由、费用和重试行为上,而不是语言上。

延伸阅读

← 返回博客列表