本文介绍了如何利用 Rust、Yellowstone gRPC 和 Vixen 框架构建实时 Jupiter 限价单监控系统。内容涵盖了从 IDL 生成类型安全解析器、处理交易流、到提取人类可读代币数据的完整技术流程,并详细演示了 Vixen v0.6.1 的宏解析与过滤新特性。
本指南介绍如何使用 Yellowstone gRPC 和 Vixen(一个基于 Rust 的 Solana 数据解析工具包)在 Rust 中构建一个实时的 Jupiter 限价单(Limit Order)监控器。该应用程序流式传输已确认的限价单交易,并以人类可读的 Token 数量和符号记录下单、成交和取消订单的操作。在此过程中,本指南将展示 Vixen v0.6.1 的三个特性:通过 proc-macro 实现基于 IDL 的解析器代码生成、用于过滤掉失败交易的 TransactionPrefilter 中的 failed 字段,以及用于可观测性的结构化追踪(tracing)。
TL;DR
include_vixen_parser! proc-macro 直接从 Jupiter Limit Order IDL 生成类型安全的解析器TransactionPrefilter 仅流式传输已确认(未失败)的交易本指南还使用了以下软件包:
| 依赖 | 版本 |
|---|---|
| rustc | 1.85.0 |
| yellowstone-vixen | 0.6.1 |
| yellowstone-vixen-core | 0.6.1 |
| yellowstone-vixen-parser | 0.6.1 |
| yellowstone-vixen-proc-macro | 0.6.1 |
| yellowstone-vixen-yellowstone-grpc-source | 0.6.1 |
| borsh | 1 |
| bs58 | 0.5 |
| chrono | 0.4 |
| reqwest | 0.12 |
| serde | 1 |
| clap | 4 |
| rustls | 0.23 |
| toml | 0.8 |
| tracing | 0.1 |
| tracing-subscriber | 0.3 |
Yellowstone gRPC 是一种基于 Geyser 插件系统构建的、用于 Solana 的高性能数据流解决方案。Yellowstone gRPC 不是通过轮询 RPC 端点获取新数据或管理 WebSocket 重新连接,而是将账户更新、交易和槽(slot)通知作为连续流推送到你的应用程序。与传统方法相比,这提供了更低的延迟和更高的吞吐量。
Quicknode 通过 Yellowstone gRPC 插件 提供 Yellowstone gRPC 服务。你可以从 Quicknode 控制面板 在任何 Solana 端点上启用它。该付费插件为你的应用程序提供专用的 gRPC 端点和身份验证 Token。
关键过滤功能包括:
Vixen 是一个开源的 Rust 框架,用于在 Yellowstone gRPC 之上构建 Solana 数据流水线。它自动处理 gRPC 订阅、交易过滤和反序列化,因此应用程序代码接收的是类型化的 Rust 结构体,而不是原始字节。
Vixen 使用 Parser + Handler 架构来分离数据提取和业务逻辑:
根据 Vixen v0.6.1 发布说明,此版本引入了三个本指南将展示的以生产为重点的改进:
include_vixen_parser! 直接从程序的 IDL 生成类型安全的解析器,消除了手动编写反序列化代码的需要TransactionPrefilter 中的 failed 字段:从流中排除失败的交易,以便只有确认的链上事件到达你的处理程序tracing crate 而不是普通的 log/env_logger 提供结构化、可过滤的日志输出Vixen 和 Carbon 都是用于解析 Solana 程序数据的 Rust 框架。它们共享相同的核心方法:通过 Yellowstone gRPC 流式传输交易,将指令数据解码为类型化结构体,并在处理程序中处理事件,但在生成解析器的方式和支持的数据源方面有所不同:
| 特性 | Vixen | Carbon |
|---|---|---|
| Parser 生成 | 编译时通过 include_vixen_parser! proc-macro 进行 IDL 代码生成 |
预构建的解码器 crate + 从 IDL 进行 CLI 代码生成 |
| 数据源 | Yellowstone gRPC | Yellowstone gRPC, JITO Shredstream, JSON RPC |
| 内置程序支持 | 无 —— 从任何 Anchor IDL 生成 | 60+ 个针对热门程序的预构建解码器 |
| 受支持程序的设置 | 获取 IDL → 转换为 Codama → 调用宏 | 添加单个 Cargo crate 依赖 |
| 可观测性 | 带有结构化 span 的 tracing crate |
Prometheus 指标 + 日志 |
| 流水线模型 | Runtime → Pipeline → Parser → Handler | Pipeline → Datasource → Decoder → Processor |
有关基于 Carbon 的指南,请参阅 Yellowstone 和 Carbon:解析实时 Solana 程序数据。
Jupiter Limit Orders 是一个链上订单簿,允许交易者下单并在市场达到指定价格时自动执行。与即时兑换不同,限价单会一直保留在链上,直到撮合者(在程序的账户中被称为 taker)以请求的价格或更好的价格成交。下单者(maker)也可以随时取消未成交的订单以取回其 Token。
本指南使用 Jupiter Limit Orders 有三个原因:
j1o2qRpjcyUwEvwtcfhEQefh773ZgjxcVRry7LDqg5X) 进行,这可以清晰地映射到 Vixen 的单流水线模型。本指南监控的三种指令是:
InitializeOrder:下单者创建一个新的限价单,指定输入/输出 Token 和数量FillOrder:撮合者以请求的价格或更好的价格成交现有订单CancelOrder:下单者取消其未成交的订单并取回其 Token对于每个事件,处理程序通过 Jupiter 的 Token API 解析人类可读的 Token 符号,并使用交易中 Token 余额元数据的十进制精度格式化原始 Token 数量。
创建项目目录和 idls 文件夹来存储 Jupiter Limit Order IDL:
cargo new jupiter-lo-vixen
cd jupiter-lo-vixen
mkdir idls
将 Cargo.toml 的内容替换为以下内容。所有 Vixen crate 均固定为 v0.6.1:
Cargo.toml
[package]
name = "jupiter-lo-vixen"
version = "0.1.0"
edition = "2021"
[dependencies]
yellowstone-vixen = "0.6.1"
yellowstone-vixen-core = "0.6.1"
yellowstone-vixen-parser = "0.6.1"
yellowstone-vixen-proc-macro = "0.6.1"
yellowstone-vixen-yellowstone-grpc-source = "0.6.1"
borsh = { version = "1", features = ["derive"] }
bs58 = "0.5"
chrono = { version = "0.4", features = ["clock"] }
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
clap = { version = "4", features = ["derive"] }
rustls = { version = "0.23", features = ["ring"] }
toml = "0.8"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
请注意,yellowstone-vixen-core 必须是一个直接依赖项(而不仅仅是通过 yellowstone-vixen 传递的间接依赖),因为 proc-macro 生成的代码会通过 crate 名称引用它。
以下是关键依赖项的作用:
yellowstone-vixen / yellowstone-vixen-core:Vixen 运行时和核心 trait(Parser、Handler、Pipeline)yellowstone-vixen-proc-macro:用于基于 IDL 的代码生成的 include_vixen_parser! 宏yellowstone-vixen-yellowstone-grpc-source:将 Vixen 运行时连接到 Yellowstone gRPC 端点borsh:Solana 程序使用的序列化格式(生成的解析器所需)bs58:交易签名的 Base58 编码chrono:日志输出的时间戳格式化reqwest:用于从 Jupiter API 获取 Token 符号的 HTTP 客户端clap:用于 --config 标志的命令行参数解析rustls:gRPC 连接所需的 TLS 提供程序tracing / tracing-subscriber:具有基于环境的过滤功能的结构化日志在项目根目录下创建 Vixen.toml。这是应用程序唯一需要的配置文件:
Vixen.toml
[source]
endpoint = "YOUR_QN_ENDPOINT:10000"
x-token = "YOUR_X_TOKEN"
timeout = 120
commitment-level = "confirmed"
accept-compression = "zstd"
你可以在 Quicknode 控制面板 中你的 Solana Mainnet 端点的 Yellowstone gRPC 插件设置下找到这些值。控制面板显示的端点 URL 格式为 https://your-endpoint-name.solana-mainnet.quiknode.pro/abc123。将 :10000 附加到主机名以用于 endpoint 字段,并将结尾的 Token (abc123) 用作你的 x-token。有关更多详细信息,请参阅 Yellowstone gRPC 文档。
你不必为每个指令编写手动反序列化代码,而是提供 Codama 格式的程序 IDL,include_vixen_parser! 宏会在编译时生成一个完整的类型安全解析器。本节将引导你完成完整流程,以便你可以将其应用于任何 Anchor 程序。
获取 Jupiter Limit Order v2 IDL:
Vixen 仓库已经包含了一个现成的 Jupiter Limit Order v2 的 Codama IDL,位于 tests/idls/lo_v2.json。你可以直接将其复制到你的 idls/ 文件夹中,然后直接跳到调用 Proc-Macro。下面的步骤将引导你完成完整的“获取并转换”流程,以便你可以将相同的过程应用于尚未提供 Codama IDL 的任何 Anchor 程序。
使用 Anchor CLI 获取 Jupiter Limit Order v2 程序的链上 IDL。将 YOUR_QN_RPC_URL 替换为你的 Quicknode Solana Mainnet RPC 端点:
anchor idl fetch j1o2qRpjcyUwEvwtcfhEQefh773ZgjxcVRry7LDqg5X \
--provider.cluster YOUR_QN_RPC_URL \
-o idls/lo_v2_raw.json
IDL 包含完整的程序接口定义:指令名称、账户结构、参数类型和程序地址。这是 Vixen 用来生成类型化 Rust 代码的内容。
一个 Anchor IDL 的陷阱
当你使用 anchor idl 命令下载 IDL 时,它带有一个小的不一致性,这会阻碍 Codama 的转换器。在 initialize_order 指令中,一个 PDA 种子引用了名为 unique_id 的参数,但在该 IDL 中,unique_id 实际上嵌套在 params 结构体中,而不是顶级参数。Codama 在顶级查找它,找不到,然后报错。
当你将 Codama 与其他程序的 IDL(其 IDL 在技术上是有效的 Anchor 输出,但路径引用与实际参数结构不匹配)一起使用时,可能会遇到此类问题。在使用尚未具备 Codama 格式 IDL 的 IDL 与 Vixen 配合时,你可能必须手动排除此类故障。
要修复此问题,请打开 idls/lo_v2_raw.json 并在 initialize_order 指令的账户种子中找到此行(大约在第 621 行):
idls/lo_v2_raw.json
{
"kind": "arg",
"path": "unique_id"
}
将其更改为:
idls/lo_v2_raw.json
{
"kind": "arg",
"path": "params.unique_id"
}
转换为 Codama 格式:
将根项目文件夹初始化为 Node.js 项目,以便我们可以运行 Codama 命令:
npm init -y
npm install -g @codama/cli
npm install @codama/nodes-from-anchor
@codama/nodes-from-anchor 是在转换过程中理解 Anchor IDL 格式所必需的。
将原始 Anchor IDL 转换为 Codama 格式:
codama convert idls/lo_v2_raw.json idls/lo_v2.json
这个一次性的转换步骤为任何 Anchor 程序解锁了 Vixen 的编译时代码生成。
创建包含以下内容的 src/parser.rs:
src/parser.rs
use yellowstone_vixen_proc_macro::include_vixen_parser;
include_vixen_parser!("idls/lo_v2.json");
这个单一的宏调用在编译时生成:
limit_order2 模块Instructions 枚举,其变体对应 IDL 中的每个指令(InitializeOrder、FillOrder、CancelOrder 等)InstructionParserTransactionPrefilter:程序地址(仅限 Jupiter LO v2)、failed: Some(false) (v0.6.1 中的新功能) 以排除失败的交易,以及 vote: Some(false) 以排除投票交易Vixen v0.6.1 使用 tracing crate 进行结构化、可过滤的日志输出,取代了 Vixen 早期版本中使用的旧的 log/env_logger 模式。
tracing 订阅器遵循 RUST_LOG 环境变量,因此你可以在运行时控制详细程度,而无需重新编译:
RUST_LOG=info:显示处理程序输出和 Vixen 生命周期事件RUST_LOG=debug:添加 gRPC 连接详细信息和流订阅活动RUST_LOG=warn:仅显示警告和错误初始化发生在 main() 中任何其他代码运行之前,确保所有 Vixen 内部组件和你的处理程序代码都能从结构化日志中受益。
创建 src/handlers.rs。该处理程序从生成的解析器接收解析后的指令,并记录三个订单事件:下单、成交和取消。
该处理程序包含辅助函数,可从 Jupiter 的 Token API 获取人类可读的 Token 符号,并将其缓存以避免重复查找。它还从交易的 Token 余额元数据中提取十进制精度,以人类可读的形式显示金额(例如,1.5 USDC 而不是 1500000)。
src/handlers.rs
use std::{collections::HashMap, sync::Mutex};
use yellowstone_vixen::vixen_core::instruction::InstructionUpdate;
use crate::parser::limit_order2;
fn fmt_amount(raw: u64, decimals: u32) -> String {
if decimals == 0 {
return raw.to_string();
}
let s = format!("{:.prec$}", raw as f64 / 10f64.powi(decimals as i32), prec = decimals as usize);
s.trim_end_matches('0').trim_end_matches('.').to_string()
}
fn fmt_token(amount: &str, symbol: &str, mint: &str) -> String {
if symbol == mint {
format!("{amount} {mint}")
} else {
format!("{amount} {symbol} ({mint})")
}
}
async fn fetch_symbol(mint: &str) -> String {
#[derive(serde::Deserialize)]
struct Token {
id: String,
symbol: String,
}
let url = format!("https://api.jup.ag/tokens/v2/search?query={mint}");
let fallback = mint.to_string();
let Ok(resp) = reqwest::get(&url).await else {
return fallback;
};
let Ok(tokens) = resp.json::<Vec<Token>>().await else {
return fallback;
};
tokens
.into_iter()
.find(|t| t.id == mint)
.map(|t| t.symbol)
.unwrap_or(fallback)
}
#[derive(Debug, Default)]
pub struct LimitOrderHandler {
symbol_cache: Mutex<HashMap<String, String>>,
}
impl LimitOrderHandler {
async fn get_symbol(&self, mint: &str) -> String {
{
let cache = self.symbol_cache.lock().unwrap();
if let Some(sym) = cache.get(mint) {
return sym.clone();
}
}
let symbol = fetch_symbol(mint).await;
self.symbol_cache
.lock()
.unwrap()
.insert(mint.to_string(), symbol.clone());
symbol
}
}
impl yellowstone_vixen::Handler<limit_order2::Instructions, InstructionUpdate>
for LimitOrderHandler
{
async fn handle(
&self,
value: &limit_order2::Instructions,
raw: &InstructionUpdate,
) -> yellowstone_vixen::HandlerResult<()> {
use limit_order2::instruction::Instruction;
let sig = bs58::encode(&raw.shared.signature).into_string();
let ts = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.6fZ");
let pre = &raw.shared.pre_token_balances;
let post = &raw.shared.post_token_balances;
let find_decimals = |mint: &str| {
pre.iter()
.chain(post.iter())
.find(|b| b.mint == mint)
.and_then(|b| b.ui_token_amount.as_ref())
.map(|u| u.decimals)
};
match &value.instruction {
Instruction::InitializeOrder { accounts, args } => {
let input_mint_str = accounts.input_mint.to_string();
let output_mint_str = accounts.output_mint.to_string();
let input_symbol = self.get_symbol(&input_mint_str).await;
let output_symbol = self.get_symbol(&output_mint_str).await;
let making_amount = find_decimals(&input_mint_str)
.map(|d| fmt_amount(args.making_amount, d))
.unwrap_or_else(|| args.making_amount.to_string());
let taking_amount = find_decimals(&output_mint_str)
.map(|d| fmt_amount(args.taking_amount, d))
.unwrap_or_else(|| args.taking_amount.to_string());
let expired_at = args.expired_at
.and_then(|t| chrono::DateTime::from_timestamp(t, 0))
.map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
.unwrap_or_else(|| "None".to_string());
let making = fmt_token(&making_amount, &input_symbol, &input_mint_str);
let taking = fmt_token(&taking_amount, &output_symbol, &output_mint_str);
tracing::info!(
tx = %sig,
maker = %accounts.maker,
making_amount = %making,
taking_amount = %taking,
expired_at = %expired_at,
"New limit order placed - {ts}",
);
},
Instruction::FillOrder { accounts, args } => {
let input_mint_str = accounts.input_mint.to_string();
let output_mint_str = accounts.output_mint.to_string();
let input_symbol = self.get_symbol(&input_mint_str).await;
let input_amount = find_decimals(&input_mint_str)
.map(|d| fmt_amount(args.input_amount, d))
.unwrap_or_else(|| args.input_amount.to_string());
let input = fmt_token(&input_amount, &input_symbol, &input_mint_str);
tracing::info!(
tx = %sig,
taker = %accounts.taker,
output_mint = %output_mint_str,
input_amount = %input,
"Limit order filled - {ts}",
);
},
Instruction::CancelOrder { accounts, .. } => {
tracing::info!(
tx = %sig,
maker = %accounts.maker,
order = %accounts.order,
"Limit order cancelled - {ts}",
);
},
_ => {},
}
Ok(())
}
}
处理程序为生成的 limit_order2::Instructions 类型实现了 Vixen 的 Handler trait。它对三种指令变体进行模式匹配:
InitializeOrder:记录下单者的地址、带有人类可读数量的输入/输出 Token,以及订单的过期时间戳FillOrder:记录撮合者的地址、输出 Mint,以及正在成交的输入金额CancelOrder:记录下单者的地址和正在取消的订单账户_(通配符):静默忽略所有其他指令(费用更新、管理操作等)该处理程序还包括用户友好的格式化,例如 Token 数量的十进制转换以及通过 Jupiter 的 Token API 进行的代码符号查找。对于超高性能应用程序,这些站外 API 调用可能会影响性能。
将生成的 src/main.rs 替换为以下代码。这会将一切联系在一起:tracing 初始化、TLS 加密提供程序、配置加载,以及带有指令流水线的 Vixen 运行时。
src/main.rs
use std::path::PathBuf;
use clap::Parser as _;
use yellowstone_vixen::Pipeline;
use yellowstone_vixen_yellowstone_grpc_source::YellowstoneGrpcSource;
mod handlers;
mod parser;
use handlers::LimitOrderHandler;
use parser::limit_order2;
#[derive(clap::Parser)]
#[command(version, author, about = "Monitor Jupiter Limit Order v2 events via Yellowstone Vixen")]
struct Opts {
#[arg(long, short)]
config: PathBuf,
}
fn main() {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
rustls::crypto::ring::default_provider()
.install_default()
.expect("Failed to install rustls crypto provider");
let Opts { config } = Opts::parse();
let config = std::fs::read_to_string(config).expect("Error reading config file");
let config = toml::from_str(&config).expect("Error parsing config");
yellowstone_vixen::Runtime::<YellowstoneGrpcSource>::builder()
.instruction(Pipeline::new(limit_order2::InstructionParser, [LimitOrderHandler::default()]))
.build(config)
.run();
}
运行时设置执行四个步骤:
EnvFilter 初始化 tracing-subscriber,以便 RUST_LOG 环境变量在运行时控制日志详细程度,而无需重新编译rustls 加密提供程序,这是 Quicknode 的 gRPC TLS 连接所必需的Vixen.toml 以获取端点、Token 和流设置Pipeline 构建 Vixen Runtime,该 Pipeline 将生成的 InstructionParser 连接到 LimitOrderHandler,然后开始流式传输Pipeline::new 调用将解析器连接到处理程序。生成的 InstructionParser 处理订阅过滤(程序地址、失败交易、投票交易)和反序列化。你的处理程序仅接收 Jupiter Limit Order v2 程序的已解析、类型化的指令数据。
构建项目:
cargo build
使用配置标志运行它。设置 RUST_LOG=info 以查看处理程序输出和 Vixen 生命周期事件:
RUST_LOG=info cargo run -- --config ./Vixen.toml
连接后,你将看到 Vixen 生命周期日志行,随后是 Mainnet 上发生的限价单事件。以下是三种事件类型的示例输出:
2026-04-08T14:32:01.123456Z INFO jupiter_lo_vixen: New limit order placed - 2026-04-08T14:32:01.123456Z tx=5UjQ...abc maker=7xKm...def making_amount="100 USDC (EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v)" taking_amount="1.5 SOL (So11111111111111111111111111111111111111112)" expired_at=2026-04-15T00:00:00Z
2026-04-08T14:33:15.654321Z INFO jupiter_lo_vixen: Limit order filled - 2026-04-08T14:33:15.654321Z tx=3Kpx...ghi taker=9mRv...jkl output_mint=So11111111111111111111111111111111111111112 input_amount="100 USDC (EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v)"
2026-04-08T14:35:42.789012Z INFO jupiter_lo_vixen: Limit order cancelled - 2026-04-08T14:35:42.789012Z tx=8FnY...mno maker=7xKm...def order=4vTq...pqr
每个条目都包含一个 ISO 8601 时间戳、交易签名以及该事件类型的相关账户和金额。Token 数量以人类可读的形式显示,并带有从 Jupiter 的 Token API 解析的代码符号。
你构建的架构非常灵活。以下是一些扩展它的方法:
什么是 Yellowstone gRPC 以及它如何实现 Solana 上的实时监控?
Yellowstone gRPC 是一种用于流式传输 Solana 区块链数据(包括交易和账户更新)的高性能 gRPC 接口。它允许 Rust 应用程序订阅特定程序(如 Jupiter),从而在不轮询的情况下获得低延迟的限价单种子(feed)。
在 Solana 开发背景下,什么是 Vixen?
Vixen 是一个开源的 Rust 框架,用于在 Yellowstone gRPC 之上构建类型化的数据流水线。它自动处理 gRPC 连接、流过滤和指令反序列化,因此你可以针对类型化的 Rust 结构体编写业务逻辑,而不是原始字节。它的核心抽象是 Parser(解码指令)、Handler(处理指令)和 Pipeline(在托管的 Runtime 中连接前两者)。
我可以使用 Vixen 监控 Jupiter Limit Orders 以外的程序吗?
是的。Vixen 的 proc-macro 适用于任何发布了 IDL 的 Anchor 程序。使用 Anchor CLI 获取 IDL,将其转换为 Codama 格式,并将其传递给 include_vixen_parser!。生成的解析器和流水线结构保持不变。只有 IDL 文件和你的处理程序逻辑会改变。
为什么 TransactionPrefilter 会排除失败的交易?
Vixen v0.6.1 在其 TransactionPrefilter 中引入了一个 failed 字段。将其设置为排除失败交易可确保只有确认、成功执行的交易到达你的处理程序。对于限价单监控器,这可以防止记录从未在链上实际执行的失败成交尝试等事件。
我需要 Quicknode 账户才能使用 Vixen 吗?
Vixen 本身是一个开源框架。但是,它需要一个 Yellowstone gRPC 端点来流式传输数据。Quicknode 的 Yellowstone gRPC 插件为 Solana Mainnet 提供了一个托管的 gRPC 端点,你可以在控制面板的任何 Quicknode Solana 端点上启用它。
什么是 Codama,为什么需要转换步骤?
Codama 是一种 IDL 格式,它提供比 Anchor 原生 IDL 格式更丰富的类型信息。Vixen 的 proc-macro 需要 Codama 格式来生成准确的类型化结构体和枚举。codama convert 命令桥接了这两种格式。这个一次性的转换步骤为任何 Anchor 程序解锁了 Vixen 的编译时代码生成。
你已经构建了一个实时的 Jupiter 限价单监控器,它通过 Yellowstone gRPC 流式传输已确认的交易,使用 Vixen 的 IDL 代码生成将其解析为类型化的 Rust 结构体,并以人类可读的 Token 输出记录下单、成交和取消订单的操作。从这里开始,你可以通过换入任何 Anchor 程序的 IDL,将相同的模式应用于该程序,使 Vixen 成为构建 Solana 数据流水线的通用基础。
有疑问?加入 Quicknode Discord 或关注 @quicknode 获取更新。
如果你有任何反馈或对新话题的需求,请告诉我们。我们很乐意听取你的意见。
- 原文链接: quicknode.com/guides/sol...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!