链上程序无法直接访问链下数据。它们依赖预言机(oracles)来引入资产价格、事件结果或 API 响应等信息。如果没有这些预言机,程序就只能局限于已存储在链上的状态。
Switchboard 是一个多链去中心化预言机网络,最初建立在 Solana 上,旨在为智能合约提供可靠的链下数据(如价格、天气和事件数据)。在本教程中,我们将构建一个 Solana 程序,从 Switchboard 读取当前的 SOL/USD 价格。
在本教程中,我们将完成三件事:
- 构建并部署一个从 Switchboard 读取数据的 Solana 程序。
- 为我们的程序初始化并配置一个新的 Switchboard 喂价(price feed)。
- 编写一个客户端脚本,与我们在 Devnet 上的程序进行交互以显示价格。
在构建 Solana 程序之前,让我们先了解一下 Switchboard 是如何工作的。
Switchboard 的工作原理
Switchboard 使用以下 4 个关键组件来允许 Solana 程序读取链下数据:
- Jobs: Switchboard 中的 job 是按顺序执行的任务集合。每个任务执行特定操作。例如,一个任务可以从外部 API 端点获取数据,而另一个任务负责解析响应。
- Data Sources: 这是链下数据的来源,比如 Binance、Coinbase 和 Pyth。Switchboard 的 job 从这些数据源获取数据。
- Oracles: 这是执行 job 的 Switchboard 网络节点。每个 oracle 从所有配置的数据源获取数据,对其进行聚合,并向链上的 feed 提交一个单一的
i128值。 - Feed: feed 是存储 oracle 提交结果的链上账户。当你的程序读取 feed 时,它会接收由各个 oracle 结果生成的聚合值,Switchboard SDK 会将其转换为
Decimal类型以便进行小数运算。
总结起来,以上组件的协同工作方式如下:
- 你定义 job 以指定从何处获取数据(数据源)
- Oracle 节点执行这些 job,并将结果提交到链上的 feed 账户中
- feed 将 oracle 提交的结果聚合成一个单一的
i128值 - 你的程序从 feed 中读取数据
我们将在本教程中学习这些流程是如何工作的。让我们开始实现我们的喂价功能。
前置准备
为了能够顺利跟练,你需要一个包含以下已安装工具的 Solana 开发环境:
- Solana CLI 和 Anchor:用于编写、构建和部署 Solana 程序。如果你还没有配置好它们,请阅读本系列的第一篇文章。
- Bun:作为运行客户端脚本的包管理器。在终端运行此命令
curl -fsSL https://bun.sh/install | bash来安装 bun。
我们将使用独立的脚本而不是单元测试来与部署的程序进行交互。这是因为喂价会在 Devnet 上更新,而我们希望展示实时预言机数据是如何流入链上逻辑的。
在终端运行以下命令,将你的 Solana 集群设置为 Devnet:
solana config set --url [https://api.devnet.solana.com](https://api.devnet.solana.com/)
你还需要 Devnet 上的一些 SOL 来支付交易费用。你可以使用 solana airdrop 从水龙头请求测试 SOL:
solana airdrop 2 # for 2 devnet SOL.
你一次只能在 Devnet 水龙头上请求 2 枚 SOL。由于存在频率限制,你可以先请求 2 枚,稍等片刻后再请求更多。
Solana slots
在 Solana 中,时间是以 slots(时隙)来衡量的,这是网络时间推进的连续间隔;随着网络的发展,slot 编号不断增加,它被用作一种简单的时钟来对链上事件进行排序。这与 Ethereum 的区块高度(block.number)类似,因为它们都代表着时间的推移和事件的排序。
记住 slot 这个概念,Switchboard 使用它来衡量数据的陈旧度,我们将在本文后面看到它是如何被使用的。
程序设置
我们首先创建一个 Anchor 项目来定义 Solana 程序,该程序将从 Switchboard 拉取价格数据。
anchor init switchboard-demo
cd switchboard-demo
更新你 Anchor.toml 的 provider 中的 cluster 字段以使用 Devnet,因为我们将会在 Devnet 上进行开发:
[provider]
cluster = "Devnet"
wallet = "~/.config/solana/id.json"
接下来,我们将把 switchboard-on-demand crate 添加到 programs/switchboard-demon/src/Cargo.toml 文件的 dependencies 部分。这是我们将要使用的 Switchboard crate。
[dependencies]
anchor-lang = "0.31.1"
switchboard-on-demand = "0.5.3"
在 programs/switchboard-demo/src/lib.rs 中,我们编写一个具备以下功能的程序:
- 读取 Switchboard 按需分配的 feed 账户数据作为原始字节。
- 将 feed 的原始字节解析为来自
switchboard-on-demandcrate 的PullFeedAccountData结构体。 - 在解析后的 feed 上调用
get_value方法,并使用以下参数来验证和提取最新价格:max_stale_slots: 设置自 feed 账户上次更新以来的最大 Solana slot 数。如果 feed 比这个限制更旧,feed.get_value会失败。min_samples: 设置有效价格所需的最少 oracle 提交数量。only_positive: 当为 true 时,拒绝非正数值(≤ 0)。对于必须始终为正数的价格或数量非常有用。
- 使用
msg!记录 SOL/USD 的价格。
use anchor_lang::prelude::*;
use switchboard_on_demand::{
on_demand::accounts::pull_feed::PullFeedAccountData,
prelude::rust_decimal,
};
use rust_decimal::Decimal;
declare_id!("iSYBH57FJPsqKnVxz8pyqPvCLEBH63y95Vgk346utR2");
#[program]
pub mod switch_on_demand_price_feed {
use super::*;
pub fn read_price(ctx: Context<ReadPrice>) -> Result<()> {
// Step 1: READS THE FEED ACCOUNT's RAW BINARY (bytes)
let data_slice = ctx.accounts.feed.data.borrow();
// Step 2: PARSE FEED ACCOUNT DATA
let feed = PullFeedAccountData::parse(data_slice).unwrap();
// Step 3: RETRIEVE FEED VALUE WITH SLOT, SAMPLING CONSTRAIN,
// AND THE PARAMETER THAT DETERMINES WHETHER THE RECEIVED VALUE
// IS POSITIVE OR NEGATIVE
let price: Decimal = feed.get_value(
&Clock::get()?,
/*max_stale_slots=*/ 100,
/*min_samples=*/ 3,
/*only_positive=*/ true,
).unwrap();
// Step 4: LOGS THE SOL/USD PRICE WITH `msg!`.
msg!("SOL/USD price: {}", price);
Ok(())
}
}
#[derive(Accounts)]
pub struct ReadPrice<'info> {
/// CHECK: This is a Switchboard on-chain feed (PullFeedAccount)
pub feed: AccountInfo<'info>,
}
注意步骤 3 中有关 slot 陈旧度和采样限制的注释。每次 Switchboard 在链上写入新值时,它都会记录当前的 slot 编号。当你调用 feed.get_value(&Clock::get()?, max_stale_slots, ...) 时,Switchboard 会将当前 slot 与 feed 的最后一次更新 slot 进行比较。
如果差值超过 max_stale_slots(在我们的代码中为 100),get_value 将返回一个错误。指令会执行失败,并且交易会被拒绝。
此外,min_samples 参数确保我们从足够数量的 oracle 中聚合响应以提高准确性。在我们的示例中,我们将其设置为 3,这意味着结果必须包含至少 3 个 oracle 响应的数据。在稍后讨论链下初始化时,我们将看到如何配置这些 oracle。
接下来,通过运行以下命令来构建并部署程序:
anchor build && anchor deploy
部署成功后将返回 program Id 和签名,如下所示:

我们的链上程序现已部署并能够记录 SOL/USD 的价格,但我们还不能使用它,因为尽管程序已经准备好从 feed 账户中读取价格,但还没有指定从中读取的 feed 来源。接下来,我们将通过设置链下 feed 来解决这个问题。
链下 feed 设置
回想一下,feed 是存储 oracle 提交结果的链上账户。
设置 feed 包括两个步骤,我们将在接下来进行介绍:
- Feed 配置和初始化
- Feed 更新
1/2 Feed 配置和初始化
Feed 配置定义了预言机的数据源和聚合规则(类似于链上的规则,例如 max_stale_slots)。你需要指定要查询哪些外部 API 或链上预言机、需要多少个响应,以及允许的数据源之间的偏差。在任何内容上链之前,此配置以 JavaScript 对象的形式存在。
Feed 初始化采用该配置并在链上创建实际账户。初始化交易存储你的 feed 元数据,将其绑定到 oracle 节点池(在 Switchboard 中称为“oracle queue”),并生成你的程序在请求价格数据时必须引用的公钥。
与 Chainlink 预言机不同,Switchboard feed 不会自动更新。它使用拉取模型。你的程序必须通过链下脚本触发 job 以获取新数据并更新链上的 feed。
每个 job 包含:
- Tasks array:包含按顺序执行的操作任务列表
- Task types:Tasks array中的不同任务,例如:
httpTask:从 URL 获取 feed 数据jsonParseTask:使用路径查询从httpTask返回的 JSON 响应中提取特定值
下面的示例展示了包含两个任务的 job:一个从 Coinbase API 获取汇率数据,另一个负责解析响应。

通常,你需要自己实现这些 job,每个 job 使用一个函数。但是,为了简单起见,我们在本例中不会手动实现获取逻辑。
Switchboard 团队已经提供了一个公开的 utils.ts 文件,其中包含常见的 job 获取实现。我们将直接使用它们。打开 GitHub 上的 utils 文件,复制代码内容,并将其粘贴到 /scripts/utils.ts 中。
设置多个数据源
上面的图表显示了一个仅包含一个数据源的任务。在实际程序中,你需要多个数据源,这意味着你将拥有多个 job。多数据源可以防止单点故障,并允许 feed 通过偏差检查过滤掉异常值。
创建初始化脚本
在 /scripts 文件夹中创建一个 initializeFeeds.ts 文件,并运行下面的命令来安装我们与 Switchboard 网络交互所需的依赖项。我们还将使用 @solana/web3.js,它已包含在我们安装的 Anchor 中。
yarn add @switchboard-xyz/on-demand @switchboard-xyz/common
我们的脚本执行了 5 个步骤:
- 定义四个 job 来读取
SOL/USD价格:一个从 Pyth 的链上预言机读取,三个从 REST API 读取 - 使用聚合规则(如
maxStaleness、minimumSamples等)配置 feed - 生成一个新的 feed 账户 keypair,以创建一个唯一的链上地址来存储 feed 的价格数据
- 使用 Crossbar(Switchboard 用于上传到 IPFS 的服务)在链下存储 job 的定义。这避免了链上存储成本。Crossbar 会返回 IPFS 的内容哈希,预言机将使用它来获取存储的 job 定义。我们将会在 feed updates 部分看到预言机是如何使用该哈希的。
- 构建并发送 feed 初始化交易以在链上创建 feed
import { PublicKey } from "@solana/web3.js";
import * as sb from "@switchboard-xyz/on-demand";
import { AnchorUtils, PullFeed } from "@switchboard-xyz/on-demand";
import { CrossbarClient, decodeString } from "@switchboard-xyz/common";
import {
buildCoinbaseJob,
buildBinanceJob,
buildPythJob,
buildBybitJob,
TX_CONFIG,
} from "./utils";
const crossbarClient = new CrossbarClient(
"https://crossbar.switchboard.xyz",
/* verbose= */ true
);
// 1. DEFINE FOUR FEED JOBs to READ SOL/USD PRICE
const FEED_JOBS = [
// Pyth oracle passing the SOL/USD price feed public key
buildPythJob("H6ARHf6YXhGYeQfUzQNGk6rDNnLBQKrenN712K4AQJEG"),
// Web API Endpoints
buildCoinbaseJob("SOL-USD"),
buildBinanceJob("SOLUSDT"),
buildBybitJob("SOLUSDT"),
];
(async function main() {
// Load wallet and RPC connection from Solana CLI config (~/.config/solana/id.json)
// Then fetch the network's default oracle queue
const { keypair, connection, program } = await AnchorUtils.loadEnv();
const queueAccount = await sb.getDefaultQueue(connection.rpcEndpoint);
const queue = queueAccount.pubkey;
// 2. FEED CONFIGURATION
const conf: any = {
name: "SOL-USD Price Feed", // the feed name (max 32 bytes)
queue: new PublicKey(queue), // the queue of oracles to bind to
maxVariance: 1.0, // allow 1% variance between submissions and jobs
minResponses: 3, // minimum number of responses of jobs to allow
numSignatures: 3, // number of signatures to fetch per update
minSampleSize: 3, // minimum number of responses to sample for a result
maxStaleness: 100, // maximum stale slots of responses to sample
};
// 3. GENERATE FEED KEYPAIR
console.log("Initializing new data feed");
const [pullFeed, feedKp] = PullFeed.generate(program!);
// 4. STORE JOB DEFINITIONS ON IPFS
conf.feedHash = decodeString(
(await crossbarClient.store(queue.toString(), FEED_JOBS)).feedHash
);
// 5. BUILD AND SEND THE INITIALIZATION TRANSACTION
// WITH THE FEED CONFIGURATION
const initTx = await sb.asV0Tx({
connection,
ixs: [await pullFeed.initIx(conf)],
payer: keypair.publicKey,
signers: [keypair, feedKp],
computeUnitPrice: 75_000,
computeUnitLimitMultiple: 1.3,
});
console.log("Sending initialize transaction");
const sig = await connection.sendTransaction(initTx, TX_CONFIG);
await connection.confirmTransaction(sig, "confirmed");
console.log(`Feed ${feedKp.publicKey} initialized: ${sig}`);
})();
让我们解释一下上述代码中与 Switchboard 相关的关键部分:
Oracle queues
该脚本使用 sb.getDefaultQueue() 检索网络的默认 oracle queue。queue 是 Switchboard 为预言机节点提供的协调机制。当你将一个 feed 绑定到一个 queue 时,你就是在告诉 Switchboard 网络,哪个预言机节点池可以满足你的 feed 更新请求。每个 queue 都有自己的一套注册预言机、奖励参数和操作规则。在 Devnet 上,这将返回所有开发者共享的公共测试队列。
Feed 配置
在上面的代码中,feed 配置是一组规则,它将我们 job 的结果聚合成一个受信任的单一值。这种信任来源于我们配置中的多层验证:
minResponses: 我们要求每次更新至少有 3 个成功的响应,并且在计算结果时至少采样 3 次提交。这与我们链上调用get_value时的min_samples = 3相匹配。maxVariance: 我们将其设置为1.0,这意味着如果一个数据源报告的价格与其他数据源的差异超过 1%,它可以因不一致而被丢弃。maxStaleness: 这确保数据是最新的,对应于我们链上程序中的max_stale_slots值。
使用我们之前安装的 JavaScript 运行时 bun 来运行脚本:
bun run scripts/initializeFeeds.ts
运行结果将包含程序中 feed 账户的 feed 公钥,如下所示:

该 feed 现在已在 Devnet 上线,但它仍然是空的。在下一节中,我们将开始使用此公钥向其填充数据。
2/2 Feed 更新
既然我们的 feed 账户已初始化,我们需要一个不断向其中填充新数据的流程。Feed 更新是确保 feed 账户始终包含最新数据的环节。
我们将创建一个脚本 runfeeds.ts,运行一个无限循环。在每次迭代中,它都会使用 feed 公钥向 Switchboard 网络请求 job 的最新价格。来自所有预言机的数据会被聚合,然后脚本发送一笔交易,将验证过的结果存储到我们的链上 feed 账户中。
import * as sb from "@switchboard-xyz/on-demand";
import { CrossbarClient } from "@switchboard-xyz/common";
import yargs from "yargs";
import { TX_CONFIG, sleep } from "./utils";
import { PublicKey } from "@solana/web3.js";
/// Parse command-line arguments - requires the feed account public key '--feed'
const argv = yargs(process.argv).options({ feed: { required: true } })
.argv as any;
console.log(`Using feed: ${argv.feed}`);
// The main function is wrapped in an immediately-invoked async function expression (IIFE).
(async function main() {
// Load wallet keypair, RPC connection, and program
const { keypair, connection, program } = await sb.AnchorUtils.loadEnv();
// Load the default Switchboard queue account for the specified network (devnet or mainnet).
const queue = await sb.Queue.loadDefault(program!);
// Initialize a 'PullFeed' object to interact with the
// on-chain data feed public key specified in the terminal.
const feedAccount = new sb.PullFeed(program!, argv.feed!);
// Connect to Switchboard's off-chain infrastructure
const crossbar = new CrossbarClient("https://crossbar.switchboard.xyz");
const gateway = await queue.fetchGatewayFromCrossbar(crossbar as any);
// Cache address lookup tables to reduce transaction size
await feedAccount.preHeatLuts();
let runCount = 0;
console.log("Starting feed updater.");
// Start an infinite loop to continuously update the data feed.
while (true) {
try {
console.log(`\n--- Update #${++runCount} ---`);
// Request fresh oracle data. The gateway reads the feedHash from the feed account,
// retrieves job definitions from IPFS, distributes them to oracles, and returns
// their aggregated responses
const [pullIx, responses, _ok, luts] = await feedAccount.fetchUpdateIx({
gateway: gateway.gatewayUrl,
crossbarClient: crossbar as any,
});
// Check for oracle errors
let hasError = false;
for (const response of responses) {
const shortErr = response.shortError();
if (shortErr) {
console.log(`Oracle response error: ${shortErr}`);
hasError = true;
}
}
// Skip update if errors occurred or no instructions returned
if (hasError || !pullIx || pullIx.length === 0) {
console.log("Skipping update due to oracle errors or no instructions generated.");
await sleep(5000); // Wait longer before retrying if there are errors.
continue;
}
// Assemble the update instructions into a versioned transaction (v0).
const tx = await sb.asV0Tx({
connection,
ixs: [...pullIx!],
signers: [keypair], // The payer's keypair must sign the transaction.
computeUnitPrice: 200_000, // Set a priority fee to get the transaction processed faster.
computeUnitLimitMultiple: 1.3, // Add a buffer to the compute unit limit to prevent failure.
lookupTables: luts, // Include the pre-heated LUTs.
});
// Send transaction to update the feed on-chain
const sig = await connection.sendTransaction(tx, TX_CONFIG);
console.log(`✅ Transaction sent: https://explorer.solana.com/tx/${sig}?cluster=devnet`);
console.log("Waiting for confirmation...");
await connection.confirmTransaction(sig, "confirmed");
console.log("✅ Transaction confirmed!");
} catch (e) {
console.error("❌ An error occurred in the main loop:", e);
} finally {
// Pause execution for a few seconds before starting the next update cycle.
await sleep(5000);
}
}
})();
如何使用 IPFS 内容哈希
当调用 feedAccount.fetchUpdateIx() 时:
- 它从 Solana 读取 feed 账户数据,其中包含
feedHash(即在初始化期间存储的 IPFS 内容哈希) - 它使用此
feedHash向网关发送请求 - 网关(以及预言机)使用
feedHash从 IPFS 检索 job 的定义 - 预言机执行这些 job(从 Pyth、Coinbase、Binance、Bybit 获取)
- 预言机将结果返回给 Switchboard 网关,由网关对其进行聚合
fetchUpdateIx()返回指令(pullIx),如果交易成功,该指令会将聚合的预言机数据写入 feed 账户。
我们将通过命令行参数传入 feed 公钥来运行脚本,如下所示:
bun scripts/runfeeds.ts --feed GgGVgSLWAyL9Xf4fGaAQQCkmWetBjX7PCNz8kTK97DKB
运行结果将如下所示:

我们将保持此脚本持续运行,以确保我们始终能够接收到最新的数据。
读取 SOL/USD 价格
现在我们已经部署了程序并且 feed 也在持续更新,让我们编写一个脚本来读取 SOL/USD 的价格。
下面的脚本实现了以下功能:
- 定义 feed 账户: 提供之前通过我们的 feed 初始化脚本创建的 feed 账户公钥(
GgGVgSLWAyL9Xf4fGaAQQCkmWetBjX7PCNz8kTK97DKB)。 - 构建指令: Switchboard 中的 ****
.readPrice()方法对应于链上的read_price方法。.accounts({ feed })步骤将 feed 账户绑定到该指令。 - 构建交易: 将指令封装在一个包含签名和计算限制的版本化交易中。
- 模拟交易: 在花费交易费用之前,首先模拟交易以预览日志并捕捉错误。你的链上程序的
msg!输出会出现在这里,脚本将捕获并显示它。 - 将交易发送到 Solana 网络: 最后,脚本将交易发送到链上,并打印一个链接以在 Solana Explorer 中查看。
import { PublicKey } from "@solana/web3.js";
import * as sb from "@switchboard-xyz/on-demand";
import * as anchor from "@coral-xyz/anchor";
import { TX_CONFIG } from "./utils";
(async function main() {
try {
console.log("Getting SOL/USD price from Switchboard...");
// Load keypair and connection from local environment
const { keypair, connection } = await sb.AnchorUtils.loadEnv();
// Create Anchor provider and attach it
const provider = new anchor.AnchorProvider(
connection,
new anchor.Wallet(keypair),
{ commitment: "confirmed" }
);
anchor.setProvider(provider);
// Load the deployed program using Anchor's workspace
const program = anchor.workspace.switchOnDemandPriceFeed;
// ====== 1. DEFINE FEED ACCOUNT ======
// Replace with YOUR feed account address from initializeFeeds.ts output
const feedAccount = new PublicKey("GgGVgSLWAyL9Xf4fGaAQQCkmWetBjX7PCNz8kTK97DKB");
// 2. **BUILD THE INSTRUCTION**
// Build the instruction to call the 'read_price' method on-chain
const ix = await program.methods
.readPrice()
.accounts({
feed: feedAccount,
})
.instruction();
// ====== 3. BUILDING THE TRANSACTION ======
// Wrap in a versioned transaction, with the payer signing
const tx = await sb.asV0Tx({
connection,
ixs: [ix],
payer: keypair.publicKey,
signers: [keypair],
computeUnitPrice: 200_000,
computeUnitLimitMultiple: 1.3,
});
// ====== 4. TRANSACTION SIMULATION ======
// Simulate the transaction to capture logs (including our price output)
const sim = await connection.simulateTransaction(tx, TX_CONFIG);
if (sim.value.logs) {
const priceLog = sim.value.logs.find(log =>
log.includes("SOL/USD price:"));
if (priceLog) {
console.log(`✅ ${priceLog}`);
} else {
console.log("All logs:", sim.value.logs);
}
}
// ====== 5. SENDING THE TRANSACTION TO THE SOLANA NETWORK ======
// Send the actual transaction and log its signature link
const sig = await connection.sendTransaction(tx, TX_CONFIG);
console.log(`📝 Transaction: https://explorer.solana.com/tx/${sig}?cluster=devnet`);
} catch (error) {
console.error("❌ Error getting price:", error);
}
})();
现在,在一个终端中保持 runfeeds.ts 处于运行状态,打开第二个终端并运行读取脚本:
bun run scripts/showPrice.ts
你应该能够看到直接从链上程序记录的 SOL/USD 价格,这证实了整个数据管道都在正常工作。

在我们的示例中,我们只是获取并显示了价格。你可以利用 Switchboard 链上数据构建任何类型的应用。
结论
我们学习了如何与 Switchboard 预言机工作流进行交互,从多个数据源获取可靠数据并在链上使用。此过程涉及的步骤包括:
- 编写你的链上程序,该程序应当与 Switchboard feed 账户中的数据进行交互。
- 在链下初始化和配置 Switchboard feed,这将会在链上生成一个 feed 账户公钥,你的应用 feed runner 可以使用它来持续更新数据。
- 运行客户端脚本,使用公钥从 feed 读取数据
进阶练习
创建一个计算价格影响的程序,评估交易滑点将如何影响交易。该函数应该利用 Switchboard 的喂价数据。
- 从 Switchboard 检索当前的 SOL/USD 价格
- 实现一个基于交易规模的分层滑点模型
- 计算包含滑点后的实际执行价格
- 报告原始交易规模、当前市场价格、计算出的价格影响以及包含滑点后的有效执行价格。