本文介绍了 Clockwork,一个构建在 Solana 上的自动化原语,它允许创建基于事件的触发器来执行 Solana 程序指令。
Clockwork 不再受支持
截至 2023 年 8 月,Clockwork 团队已宣布,他们将不再支持 Clockwork 平台。本指南仅用于历史教育目的,不会更新。
对于现代替代方案,请查看由 Helium 维护的 Tuktuk。
Clockwork 是构建在 Solana 之上的自动化原语。它允许你创建基于事件的触发器,以执行 Solana 程序指令。这对于各种用例都很有用,例如,安排在特定时间间隔执行的交易(例如,自动执行诸如 dollar-cost-averaging 之类的操作)或安排在发生特定链上事件后执行的交易(例如,在帐户余额超过一定水平后自动执行诸如分配之类的操作)。在本指南中,我们将探讨 Clockwork 是什么以及如何使用它来自动化一个简单的 Solana 程序。
在本指南中,你将学习如何使用 Clockwork SDK 自动化 Solana 上的流程。你将:
经验要求:
请确保在继续之前已安装所需的依赖项:
| 依赖项 | 版本 |
|---|---|
| anchor-lang | 0.27.0 |
| solana-program | 1.75.0 |
| @project-serum/anchor | 0.26.0 |
| clockwork-sdk | 2.0.15 |
Clockwork 是一个为 Solana 构建的开源工具,可让你自动执行链上程序执行,而无需依赖中央服务器。Clockwork 是一个 Solana geyser 插件,安装在验证器或 RPC 节点中。该插件负责监听用户定义的 Clockwork 线程定义的触发器。
可以使用 Clockwork SDK 使用 TypeScript 或在你的链上程序中使用 Rust Crate 创建线程。本指南将使用后者创建一个线程,该线程执行一个简单的 Solana 程序指令。⏰ 是时候开始了!
Anchor 新手?
Anchor 是一个流行的开发框架,用于在 Solana 上构建程序。 要开始使用,请查看我们的 Anchor 入门指南。
在你的终端中使用以下命令创建一个新的项目目录:
mkdir clockwork-demo
cd clockwork-demo
使用以下命令创建一个新的 Anchor 项目:
anchor init clockwork-demo
因为我们只是测试如何更新程序的权限,所以我们不会更改 lib.rs 内部的默认“Initialize”程序。
要在 Solana 上构建,你需要一个 API 端点来与网络连接。你欢迎使用公共节点或部署和管理自己的基础设施;但是,如果你想要快 8 倍的响应时间,你可以将繁重的工作交给我们。
了解为什么超过 50% 的 Solana 项目选择 Quicknode 并在此处开始你的免费试用 here。我们将使用 Solana Devnet 端点。
复制 HTTP Provider 链接:
在部署你的程序之前,我们需要更新程序的配置。打开 Anchor.toml 文件并将 provider.cluster 字段更新为你的 Quicknode 端点。
[provider]
cluster = "https://example.solana-devnet.quiknode.pro/0123456/" # 👈 替换为你的 Quicknode Devnet 端点
wallet = "~/.config/solana/id.json" # 👈 替换为你的钱包路径
仔细检查你的钱包路径是否正确。你可以使用任何 .json 密钥(查看我们的 创建 Solana Vanity 地址指南)。 你将需要此钱包中的 Devnet SOL 才能部署你的程序并运行你的测试。你可以使用 solana airdrop 命令或使用以下工具获取一些:
🪂 请求 Devnet SOL
注意: 过于频繁地发送空投请求可能会触发 429(请求过多)错误。
空投 1 SOL (Devnet)
此外,在同一文件中,将 [programs.localnet] 更改为 [programs.devnet]。稍后我们将回到这里更新程序 ID。
接下来,你需要导航到 programs/clockwork-demo/Cargo.toml 并将 Clockwork SDK 添加到你的依赖项:
[dependencies]
anchor-lang = "0.27.0"
clockwork-sdk = { version = "2.0.15" }
现在一切都已设置好,让我们构建你的程序。
打开 programs/clockwork-demo/src/lib.rs 文件并将以下依赖项添加到你的文件顶部:
use anchor_lang::prelude::*;
use anchor_lang::solana_program::{
instruction::Instruction, native_token::LAMPORTS_PER_SOL, system_program,
};
use anchor_lang::InstructionData;
use clockwork_sdk::state::Thread;
这将允许我们使用 ClockworkSDK 和 Solana 系统程序。
在你的导入下方,应该有来自 Anchor 的样板代码。我们将添加一个新函数,该函数将用于切换链上开关 (toggle_switch) 和一个 response 函数,该函数将通过将消息记录到程序日志来响应我们线程的触发器。
创建两个函数和相应的结构,使你的代码如下所示:
declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");
#[program]
pub mod clockwork_demo {
use super::*;
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
// TODO initialize switch
// TODO initialize thread
Ok(())
}
pub fn toggle_switch(ctx: Context<ToggleSwitch>) -> Result<()> {
// TODO toggle switch
Ok(())
}
pub fn response(ctx: Context<Response>) -> Result<()> {
// TODO log message
Ok(())
}
}
#[derive(Accounts)]
pub struct Initialize {}
#[derive(Accounts)]
pub struct ToggleSwitch {}
#[derive(Accounts)]
pub struct Response {}
#[account]
pub struct Switch {
pub switch_state: bool,
}
pub const SWITCH_SEED: &[u8] = b"switch";
pub const THREAD_AUTHORITY_SEED: &[u8] = b"authority";
这些代码目前什么也不做,但我们将使用它作为我们程序的基础框架。请注意,我们定义了一个 Switch 账户类型和两个种子,我们将使用它们来派生我们线程权限和 switch PDA 的 PDA。我们稍后将使用它们。
从最后开始并向后工作可能会有所帮助。让我们从定义我们的 response 指令开始。触发后,我们的线程将调用此指令并将消息记录到程序日志中。为了让线程调用此指令,我们必须在上下文中传递线程及其权限。让我们更新我们的 Response 结构:
#[derive(Accounts)]
pub struct Response<'info> {
#[account(signer, constraint = thread.authority.eq(&thread_authority.key()))]
pub thread: Account<'info, Thread>,
#[account(seeds = [THREAD_AUTHORITY_SEED], bump)]
pub thread_authority: SystemAccount<'info>,
}
因为我们的响应没有执行任何需要任何账户的操作,所以我们只需要传递线程及其权限来授权交易:
thread:调用响应指令的线程(作为签名者传入,请注意,我们使用 constraint 字段来确保线程的权限等于从种子派生的线程权限)thread_authority:从种子派生的线程权限。让我们在我们的 response 函数中添加一个日志消息:
pub fn response(_ctx: Context<Response>) -> Result<()> {
msg!("Response to trigger at {}", Clock::get().unwrap().unix_timestamp);
Ok(())
}
我们只需使用 Solana 程序 Clock 来获取并记录当前的 unix 时间戳。由于我们没有使用上下文,我们可以使用 _ctx 来避免编译器警告。
接下来,让我们定义我们的 toggle_switch 指令,该指令将切换我们开关的状态。此指令将由 payer 调用以打开或关闭开关。我们的线程将监视开关的状态,并在其更改时执行我们的响应。让我们首先更新我们的 ToggleSwitch 结构:
#[derive(Accounts)]
pub struct ToggleSwitch<'info> {
#[account(mut, seeds = [SWITCH_SEED], bump)]
pub switch: Account<'info, Switch>,
#[account(mut)]
pub payer: Signer<'info>,
}
我们在这里有:
switch:我们要切换的开关账户(作为可变账户传入;请注意,我们使用 SWITCH_SEED 来派生我们的 PDA)payer:将支付交易费用的用户的钱包地址(作为签名者传入)现在让我们更新我们的 toggle_switch 函数,以根据开关的当前状态打开或关闭开关:
pub fn toggle_switch(ctx: Context<ToggleSwitch>) -> Result<()> {
let switch = &mut ctx.accounts.switch;
switch.switch_state = !switch.switch_state;
Ok(())
}
这个非常简单的函数只是将我们开关的状态切换到与其当前状态相反的状态。
让我们首先创建我们的 Initialize 结构。用以下代码替换现有的空结构:
#[derive(Accounts)]
#[instruction(thread_id: Vec<u8>)]
pub struct Initialize<'info> {
#[account(\
init,\
payer = payer,\
seeds = [SWITCH_SEED],\
bump,\
space = 8 + 1 // 8 bytes for discriminator, 1 byte for bool\
)]
pub switch: Account<'info, Switch>,
#[account(mut)]
pub payer: Signer<'info>,
#[account(address = system_program::ID)]
pub system_program: Program<'info, System>,
#[account(address = clockwork_sdk::ID)]
pub clockwork_program: Program<'info, clockwork_sdk::ThreadProgram>,
#[account(mut, address = Thread::pubkey(thread_authority.key(), thread_id))]
pub thread: SystemAccount<'info>,
#[account(seeds = [THREAD_AUTHORITY_SEED], bump)]
pub thread_authority: SystemAccount<'info>,
}
我们的 Initialize 上下文结构将接受一个 thread_id(线程的唯一标识符)作为参数,并由六个账户组成:
switch:我们将使用 9 字节的数据初始化我们的 Switch 账户,用于我们的区分器和开关状态。payer:将支付初始化账户的签名者。system_program:用于创建 Switch 账户的 Solana 系统程序。clockwork_program:用于创建 Thread 账户的 Clockwork 线程程序。thread:我们将创建的 Thread 账户。thread_authority:拥有和管理 Thread 账户的 PDA。让我们开始使用它们。用以下代码替换 initialize 函数:
pub fn initialize(ctx: Context<Initialize>, thread_id: Vec<u8>) -> Result<()> {
// 1 - Get accounts
let switch = &mut ctx.accounts.switch;
let payer = &ctx.accounts.payer;
let system_program = &ctx.accounts.system_program;
let clockwork_program = &ctx.accounts.clockwork_program;
let thread: &SystemAccount = &ctx.accounts.thread;
let thread_authority = &ctx.accounts.thread_authority;
// 2 - Prepare an instruction to be automated
let toggle_ix = Instruction {
program_id: ID,
accounts: crate::accounts::Response {
thread: thread.key(),
thread_authority: thread_authority.key(),
}
.to_account_metas(Some(true)),
data: crate::instruction::Response {}.data(),
};
// 3a - Define an account trigger to execute on switch change
let account_trigger = clockwork_sdk::state::Trigger::Account {
address: switch.key(),
offset: 8, // offset of the switch state (the discriminator is 8 bytes)
size: 1, // size of the switch state (1 byte)
};
// 3b - Define a cron trigger for the thread (every 10 secs)
let _cron_trigger = clockwork_sdk::state::Trigger::Cron {
schedule: "*/10 * * * * * *".into(),
skippable: true,
};
// 4 - Create thread via CPI
let bump = *ctx.bumps.get("thread_authority").unwrap();
clockwork_sdk::cpi::thread_create(
CpiContext::new_with_signer(
clockwork_program.to_account_info(),
clockwork_sdk::cpi::ThreadCreate {
payer: payer.to_account_info(),
system_program: system_program.to_account_info(),
thread: thread.to_account_info(),
authority: thread_authority.to_account_info(),
},
&[&[THREAD_AUTHORITY_SEED, &[bump]]],
),
LAMPORTS_PER_SOL/100 as u64, // amount
thread_id, // id
vec![toggle_ix.into()], // instructions
account_trigger, // trigger
)?;
// 5 - Initialize switch
switch.switch_state = true;
Ok(())
}
这里有很多事情要做,所以让我们分解一下。
response 函数。如果你还没有在 Solana 上进行很多构建,这可能看起来很奇怪。重要的是要理解,该指令是通过传递三个关键组件来定义的:程序 ID、指令将使用的账户以及指令将传递给程序的数据(在本例中,不需要数据)。switch_state bool)。第二个是 cron 触发器,它将每 10 秒执行一次指令。我们创建一个跨程序调用 (CPI) 到 Clockwork 线程程序,以创建 Thread 账户。
ctx.bumps.get("thread_authority").unwrap() 获取 Thread 账户权限的 bump。CpiContext::new_with_signer 以包括 clockwork_program、我们需要的账户(payer、system_program、thread 和 authority)以及 thread\authority 的种子,它将签署交易。initialize 函数)switch_state 设置为 true 以初始化 Switch 账户。哇!做得好。现在让我们构建和部署我们的程序!
让我们通过在你的终端中运行以下命令来构建我们的程序:
anchor build
几分钟后,你应该会看到如下内容:
Compiling clockwork-demo v0.1.0
Finished release [optimized] target(s) in 3.08s
如果你收到任何错误,请按照控制台的说明进行调试或重新访问上面的说明。如果遇到困难,请随时通过 Discord 与我们联系 - 我们随时提供帮助!
在部署程序之前,我们需要更新 lib.rs 和 Anchor.toml 中的程序 ID。你可以通过在你的终端中运行以下命令来找到程序 ID:
anchor keys list
从你的终端复制密钥并将其添加到 lib.rs 和 Anchor.toml。在 lib.rs 中,更新 declare_id 字段:
declare_id!("YOUR_PROGRAM_ID_HERE");
在 Anchor.toml 中,更新 id 字段:
[programs.devnet] # 确保你正在使用 devnet
test = "YOUR_PROGRAM_ID_HERE"
更新你的程序地址后,再次构建你的程序以更新这些程序地址:
anchor build
很好。你应该已准备好部署你的程序!让我们开始吧。
让我们通过在你的终端中运行以下命令将我们的程序部署到 Solana devnet:
anchor deploy
你应该会看到已成功部署:
Deploying program "test"...
Program path: ../clockwork/clockwork-demo/target/deploy/test.so...
Program Id: 9jA8jEj1opkV2zFryS6tK1jrGLdmVwtUp86TFc7Frtzs
Deploy success
现在我们的程序已部署,让我们创建一些测试以确保一切正常。首先,让我们安装 Clockwork TS SDK。在你的终端中,运行以下命令:
npm install @clockwork-xyz/sdk # 或 yarn add @clockwork-xyz/sdk
接下来,让我们在 programs/clockwork-demo/tests 目录中创建一个名为 clockwork-demo.ts 的新文件。在此文件中,我们将创建一个测试,该测试将初始化我们的 Switch 账户和 Thread 账户:
import { assert } from "chai";
import * as anchor from "@project-serum/anchor";
import { ClockWorkDemo } from "../target/types/clockwork_demo";
import { ClockworkProvider } from "@clockwork-xyz/sdk";
const { LAMPORTS_PER_SOL, PublicKey, SystemProgram, TransactionConfirmationStatus, SignatureStatus, Connection, TransactionSignature } = anchor.web3;
async function confirmTransaction(
connection: Connection,
signature: TransactionSignature,
desiredConfirmationStatus: TransactionConfirmationStatus = 'confirmed',
timeout: number = 30000,
pollInterval: number = 1000,
searchTransactionHistory: boolean = false
): Promise<SignatureStatus> {
const start = Date.now();
while (Date.now() - start < timeout) {
const { value: statuses } = await connection.getSignatureStatuses([signature], { searchTransactionHistory });
if (!statuses || statuses.length === 0) {
throw new Error('Failed to get signature status');
}
const status = statuses[0];
if (status === null) {
await new Promise(resolve => setTimeout(resolve, pollInterval));
continue;
}
if (status.err) {
throw new Error(`Transaction failed: ${JSON.stringify(status.err)}`);
}
if (status.confirmationStatus && status.confirmationStatus === desiredConfirmationStatus) {
return status;
}
if (status.confirmationStatus === 'finalized') {
return status;
}
await new Promise(resolve => setTimeout(resolve, pollInterval));
}
throw new Error(`Transaction confirmation timeout after ${timeout}ms`);
}
describe("Clockwork Demo", async () => {
// Configure Anchor and Clockwork providers
anchor.setProvider(anchor.AnchorProvider.local());
const program = await anchor.workspace.ClockWorkDemo as anchor.Program<ClockWorkDemo>;
const { connection } = program.provider;
const provider = anchor.AnchorProvider.local();
const payer = provider.wallet.publicKey;
anchor.setProvider(provider);
const clockworkProvider = ClockworkProvider.fromAnchorProvider(provider);
console.log("Initiating tests for program:", program.programId.toBase58());
console.log(`https://explorer.solana.com/address/${program.programId.toBase58()}?cluster=devnet`);
// Generate PDAs
const [switchPda] = PublicKey.findProgramAddressSync(
[anchor.utils.bytes.utf8.encode("switch")], // 👈 make sure it matches on the prog side
program.programId
);
const threadId = "thread-test-"+ new Date().getTime() / 1000;
const [threadAuthority] = PublicKey.findProgramAddressSync(
[anchor.utils.bytes.utf8.encode("authority")], // 👈 make sure it matches on the prog side
program.programId
);
const [threadAddress, threadBump] = clockworkProvider.getThreadPDA(threadAuthority, threadId);
// Fund the payer
beforeEach(async () => {
await connection.requestAirdrop(payer, LAMPORTS_PER_SOL * 100);
});
it("Initiates thread and switch", async () => {
try {
// Generate and confirm initialize transaction
const signature = await program.methods
.initialize(Buffer.from(threadId))
.accounts({
payer,
systemProgram: SystemProgram.programId,
clockworkProgram: clockworkProvider.threadProgram.programId,
thread: threadAddress,
threadAuthority: threadAuthority,
switch: switchPda,
})
.rpc();
assert.ok(signature);
let { lastValidBlockHeight, blockhash } = await connection.getLatestBlockhash('finalized');
const confirmation = await confirmTransaction(connection, signature);
assert.isNotOk(confirmation.err, "Transaction resulted in an error");
// Check if thread and switch accounts were created
const switchAccount = await program.account.switch.fetch(switchPda);
assert.ok(switchAccount.switchState, "Switch state should be true");
} catch (error) {
assert.fail(`An error occurred: ${error.message}`);
}
});
it("Toggles switch 5 times", async () => {
let slot = 0;
for (let i = 0; i < 5; i++) {
try {
// Generate and confirm Toggle
const signature = await program.methods
.toggleSwitch()
.accounts({
switch: switchPda,
payer,
})
.rpc();
assert.ok(signature);
let { lastValidBlockHeight, blockhash } = await connection.getLatestBlockhash('finalized');
const confirmation = await confirmTransaction(connection, signature);
assert.isNotOk(confirmation.err, "Transaction resulted in an error");
// Wait for 1 second before checking the thread
await new Promise(resolve => setTimeout(resolve, 1000));
// Check if the thread triggered
const execContext = (await clockworkProvider.getThreadAccount(threadAddress)).execContext;
if (execContext.lastExecAt) {
console.log(`Loop ${i+1} Slot of last thread trigger: `, execContext.lastExecAt.toNumber());
assert.ok(execContext.lastExecAt.toNumber() > slot, "Thread should have triggered");
slot = execContext.lastExecAt.toNumber();
}
// Wait for 1 second before next toggle
await new Promise(resolve => setTimeout(resolve, 1000));
} catch (error) {
assert.fail(`An error occurred: ${error.message}`);
}
}
});
});
这里有很多事情要做,所以让我们尝试分解一下:
requestAirdrop 为 payer 充值创建一个测试,该测试将初始化 Switch 和 Thread 账户
创建一个测试,该测试将切换 Switch 账户 5 次
最后一个组件有效地更改了 Switch 账户的状态,从而触发了 Thread 账户。然后,我们检查线程的上次执行时间以确保已触发该线程。很酷,对吧?
现在我们有了测试,让我们运行它们!在你的终端中,运行以下命令:
anchor test --skip-deploy --skip-build
我们跳过构建和部署步骤,因为我们已经完成了这些步骤。
如果你的程序正常工作,你应该会看到如下内容:
Initiating tests for program: 9jA8jEj1opkV2zFryS6tK1jrGLdmVwtUp86TFc7Frtzs
https://explorer.solana.com/address/9jA8jEj1opkV2zFryS6tK1jrGLdmVwtUp86TFc7Frtzs?cluster=devnet
✔ Initiates thread and switch (141ms)
Loop 1 Slot of last thread trigger: 84
Loop 2 Slot of last thread trigger: 89
Loop 3 Slot of last thread trigger: 94
Loop 4 Slot of last thread trigger: 99
Loop 5 Slot of last thread trigger: 104
✔ Toggles switch 5 times (11981ms)
2 passing (12s)
✨ Done in 13.05s.
干得好!除了在你的终端中看到成功的测试之外,你还可以在 Solana Explorer 中看到它们(我们在终端中记录了程序的链接)。
想要继续前进?尝试将你的触发器从账户更改为 cron job。如果你还记得,在我们的程序 lib.rs 中,我们创建了一个未使用的 cron_trigger 变量,该变量设置了一个每 10 秒运行一次的计划。你能修改程序以使用此触发器而不是 Switch 账户吗?以下是一些有关如何实现它的提示:
lib.rs 中 let _cron_trigger = ... 中的 _ 并在你的 CPI 中调用它(而不是 account_trigger)。如果遇到问题,请查看 Clockwork 团队的 示例 repo。
干得好。你刚刚创建了一个可以通过账户更改或 cron job 自动执行的程序。你现在可以使用此程序构建各种应用程序,包括:
如果你遇到困难、有疑问或只是想聊天,请在 Discord 或 Twitter 上给我们留言!
如果你对此指南有任何反馈,请 告诉我们。我们很乐意听取你的意见。
- 原文链接: quicknode.com/guides/sol...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!