如何使用 Clockwork 自动化 Solana 程序

本文介绍了 Clockwork,一个构建在 Solana 上的自动化原语,它允许创建基于事件的触发器来执行 Solana 程序指令。

Clockwork 不再受支持

截至 2023 年 8 月,Clockwork 团队已宣布,他们将不再支持 Clockwork 平台。本指南仅用于历史教育目的,不会更新。

对于现代替代方案,请查看由 Helium 维护的 Tuktuk

概述

Clockwork 是构建在 Solana 之上的自动化原语。它允许你创建基于事件的触发器,以执行 Solana 程序指令。这对于各种用例都很有用,例如,安排在特定时间间隔执行的交易(例如,自动执行诸如 dollar-cost-averaging 之类的操作)或安排在发生特定链上事件后执行的交易(例如,在帐户余额超过一定水平后自动执行诸如分配之类的操作)。在本指南中,我们将探讨 Clockwork 是什么以及如何使用它来自动化一个简单的 Solana 程序。

你将做什么

在本指南中,你将学习如何使用 Clockwork SDK 自动化 Solana 上的流程。你将:

  1. 学习 Clockwork 协议的基础知识
  2. 构建一个简单的 Solana 程序,该程序具有由 Clockwork 事件触发的指令
  3. 在 Solana 的 devnet 上测试该程序

你需要什么

经验要求:

请确保在继续之前已安装所需的依赖项:

本指南中使用的依赖项

依赖项 版本
anchor-lang 0.27.0
solana-program 1.75.0
@project-serum/anchor 0.26.0
clockwork-sdk 2.0.15

什么是 Clockwork?

Clockwork 是一个为 Solana 构建的开源工具,可让你自动执行链上程序执行,而无需依赖中央服务器。Clockwork 是一个 Solana geyser 插件,安装在验证器或 RPC 节点中。该插件负责监听用户定义的 Clockwork 线程定义的触发器

  • 线程是链上账户,每个账户包含一个触发器、一组指令和一个用于支付指令执行费用的 solana 余额。
  • 触发器是用户定义的事件,用于执行线程中的指令。主要有两种类型的触发器:
  1. 基于账户的,它跟踪链上账户的指定字节数据
  2. 基于时间的,它在指定的时间或间隔执行(cron jobs、基于 slot 或基于 epoch 的)

可以使用 Clockwork SDK 使用 TypeScript 或在你的链上程序中使用 Rust Crate 创建线程。本指南将使用后者创建一个线程,该线程执行一个简单的 Solana 程序指令。⏰ 是时候开始了!

启动一个新的 Anchor 项目

Anchor 新手?

Anchor 是一个流行的开发框架,用于在 Solana 上构建程序。 要开始使用,请查看我们的 Anchor 入门指南

在你的终端中使用以下命令创建一个新的项目目录:

mkdir clockwork-demo
cd clockwork-demo

使用以下命令创建一个新的 Anchor 项目:

anchor init clockwork-demo

因为我们只是测试如何更新程序的权限,所以我们不会更改 lib.rs 内部的默认“Initialize”程序。

使用你的 Quicknode 端点连接到 Solana 集群

要在 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>,
}

因为我们的响应没有执行任何需要任何账户的操作,所以我们只需要传递线程及其权限来授权交易:

  1. thread:调用响应指令的线程(作为签名者传入,请注意,我们使用 constraint 字段来确保线程的权限等于从种子派生的线程权限)
  2. 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>,
}

我们在这里有:

  1. switch:我们要切换的开关账户(作为可变账户传入;请注意,我们使用 SWITCH_SEED 来派生我们的 PDA)
  2. 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(线程的唯一标识符)作为参数,并由六个账户组成:

  1. switch:我们将使用 9 字节的数据初始化我们的 Switch 账户,用于我们的区分器和开关状态。
  2. payer:将支付初始化账户的签名者。
  3. system_program:用于创建 Switch 账户的 Solana 系统程序。
  4. clockwork_program:用于创建 Thread 账户的 Clockwork 线程程序。
  5. thread:我们将创建的 Thread 账户。
  6. 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(())
    }

这里有很多事情要做,所以让我们分解一下。

  1. 我们获取初始化我们的 SwitchThread 账户所需的所有账户。
  2. 我们准备一个要自动化的指令。当 Switch 账户更改时,此指令将由 Thread 账户执行。它将调用我们程序上的 response 函数。如果你还没有在 Solana 上进行很多构建,这可能看起来很奇怪。重要的是要理解,该指令是通过传递三个关键组件来定义的:程序 ID、指令将使用的账户以及指令将传递给程序的数据(在本例中,不需要数据)。
  3. 我们正在定义两个触发器来练习,但你只能为创建的任何线程使用一个。第一个是账户触发器,当 Switch 账户更改时,它将执行我们刚刚定义的指令(我们指定触发器在特定字节处查看该特定账户,我们要跟踪——在本例中,是 switch_state bool)。第二个是 cron 触发器,它将每 10 秒执行一次指令。
  4. 我们创建一个跨程序调用 (CPI) 到 Clockwork 线程程序,以创建 Thread 账户。

    • 首先,我们使用 ctx.bumps.get("thread_authority").unwrap() 获取 Thread 账户权限的 bump。
    • 接下来,我们定义我们的 CpiContext::new_with_signer 以包括 clockwork_program、我们需要的账户(payersystem_programthreadauthority)以及 thread\authority 的种子,它将签署交易。
    • 最后,我们传递新线程所需的数据:
      • 用于为线程提供种子的 lamports 数量(amount)(这将用于支付交易和 Clockwork 费用,并且需要补充才能使线程持续运行)
      • 线程的 ID(我们将其作为参数传递给 initialize 函数)
      • 触发线程时要执行的指令(在本例中,只有我们上面定义的 Response
      • 执行指令的触发器(在本例中,只有我们上面定义的 Account 触发器),但你可以稍后修改此触发器以尝试 cron 触发器。
  5. 最后,我们将 switch_state 设置为 true 以初始化 Switch 账户。

哇!做得好。现在让我们构建和部署我们的程序!

构建和部署

构建程序

让我们通过在你的终端中运行以下命令来构建我们的程序:

anchor build

几分钟后,你应该会看到如下内容:

   Compiling clockwork-demo v0.1.0
    Finished release [optimized] target(s) in 3.08s

如果你收到任何错误,请按照控制台的说明进行调试或重新访问上面的说明。如果遇到困难,请随时通过 Discord 与我们联系 - 我们随时提供帮助!

在部署程序之前,我们需要更新 lib.rsAnchor.toml 中的程序 ID。你可以通过在你的终端中运行以下命令来找到程序 ID:

anchor keys list

从你的终端复制密钥并将其添加到 lib.rsAnchor.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}`);
      }
    }
  });
});

这里有很多事情要做,所以让我们尝试分解一下:

  1. 导入必要的依赖项
  2. 配置 Anchor 和 Clockwork provider
  3. SwitchThread 账户生成 PDA
  4. 通过调用 requestAirdrop 为 payer 充值
  5. 创建一个测试,该测试将初始化 SwitchThread 账户

    • 生成并确认初始化交易
    • 检查是否已创建 Switch 账户
  6. 创建一个测试,该测试将切换 Switch 账户 5 次

    • 生成并确认切换交易
    • 检查是否已触发 Thread 账户(在短暂延迟后)
    • 在再次切换之前等待 1 秒

最后一个组件有效地更改了 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 Jobs

想要继续前进?尝试将你的触发器从账户更改为 cron job。如果你还记得,在我们的程序 lib.rs 中,我们创建了一个未使用的 cron_trigger 变量,该变量设置了一个每 10 秒运行一次的计划。你能修改程序以使用此触发器而不是 Switch 账户吗?以下是一些有关如何实现它的提示:

  1. 删除 lib.rslet _cron_trigger = ... 中的 _ 并在你的 CPI 中调用它(而不是 account_trigger)。
  2. 重新构建并重新部署你的程序。
  3. 更新你的测试以使用新触发器 - 你需要等待 cron job 触发 Thread 账户,而不是切换 Switch 账户。

如果遇到问题,请查看 Clockwork 团队的 示例 repo

时间到了!

干得好。你刚刚创建了一个可以通过账户更改或 cron job 自动执行的程序。你现在可以使用此程序构建各种应用程序,包括:

  • 自动化 DeFi
  • 高级游戏
  • 分析工具
  • 支付和订阅
  • 还有更多!

如果你遇到困难、有疑问或只是想聊天,请在 DiscordTwitter 上给我们留言!

我们 ❤️ 反馈!

如果你对此指南有任何反馈,请 告诉我们。我们很乐意听取你的意见。

资源

  • 原文链接: quicknode.com/guides/sol...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 1
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
QuickNode
QuickNode
江湖只有他的大名,没有他的介绍。