Solana - 如何使用 Clockwork 来自动化 Solana 程序

  • QuickNode
  • 发布于 2025-01-30 12:32
  • 阅读 10

本文介绍了Clockwork,一个基于Solana的自动化原语。通过详细的步骤,读者可以学习如何使用Clockwork SDK创建和部署事件触发器,使其能够自动执行Solana程序指令。指南涵盖了从项目创建、程序框架构建到测试的全过程,并提供了代码示例和依赖项说明。

概述

Clockwork 是构建在 Solana 上的自动化原语。它允许你创建基于事件的触发器,这些触发器可以执行 Solana 程序指令。这在许多用例中都很有用,例如安排在特定间隔(例如,自动化美元成本平均法)执行交易,或在某个链上事件发生后执行交易(例如,在账户余额超过某一水平后自动化分配)。在本指南中,我们将探讨 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 作业slot 基于或基于 epochs)

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

启动一个新的 Anchor 项目

/对 Anchor 不熟悉?

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

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

mkdir clockwork-demo
cd clockwork-demo

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

anchor init clockwork-demo

由于我们只是测试如何更新程序的合同,所以我们不会更改 lib.rs 中默认的“初始化”程序。

通过 QuickNode 端点连接到 Solana 集群

要在 Solana 上构建,你需要一个 API 端点来连接网络。你可以使用公共节点或部署和管理自己的基础设施;不过,如果你希望获得 8 倍的响应速度,你可以将繁重的工作交给我们。

看看为什么超过 50% 的 Solana 项目选择 QuickNode,并在 这里 注册一个免费帐户。我们将使用 Solana Devnet 端点。

复制HTTP提供者链接:

更新程序配置

在部署程序之前,我们需要更新程序的配置。打开 Anchor.toml 文件,将 provider.cluster 字段更新为你的 QuickNode 端点。

[provider]
cluster = "https://example.solana-devnet.quiknode.pro/0123456/" # 👈 用你的 QuickNode Devnet 端点替换
wallet = "~/.config/solana/id.json" # 👈 用你的钱包地址替换

请仔细检查你钱包的路径是否正确。你可以使用任何 .json 密钥(请查看我们的 关于创建 Solana 个性化地址的指南)。 你将需要在此钱包中存入 Devnet SOL 以便部署程序并运行测试。你可以使用 solana airdrop 命令或以下工具获取一些:

🪂请求 Devnet SOL

空投 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;

这将允许我们使用 Clockwork SDK 和 Solana 系统程序。

创建程序框架

在你的导入下面,你应有来自 Anchor 的这些模板代码。我们将添加一个新函数,该函数将用于切换链上开关 (toggle_switch),并定义一个 response 函数,该函数将在我们的线程触发时通过将消息记录到程序日志中来响应。

创建这两个函数及相应的结构,以使你的代码如下所示:

declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");

#[program]
pub mod clockwork_demo {
    use super::*;
    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        // TODO 初始化开关
        // TODO 初始化线程
        Ok(())
    }
    pub fn toggle_switch(ctx: Context<ToggleSwitch>) -> Result<()> {
        // TODO 切换开关
        Ok(())
    }
    pub fn response(ctx: Context<Response>) -> Result<()> {
        // TODO 记录消息
        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 账户类型和两个种子,用于推导我们的线程权限和开关 PDAs。稍后我们将使用它们。

定义响应指令

从结尾开始倒推是有帮助的。让我们先定义我们的 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: 从种子推导的线程权限。

我们随后在响应函数中添加一条日志消息:

    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 字节用于鉴别符,1 字节用于布尔值\
    )]
    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 - 获取账户
        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 - 准备一个指令以进行自动化
        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 - 定义在开关改变时执行的账户触发器
        let account_trigger = clockwork_sdk::state::Trigger::Account {
            address: switch.key(),
            offset: 8, // 触发器关注的开启状态的偏移(鉴别符是 8 字节)
            size: 1,   // 开关状态的大小(1 字节)
        };
        // 3b - 定义线程(每 10 秒)使用的 cron 触发器
        let _cron_trigger = clockwork_sdk::state::Trigger::Cron {
            schedule: "*/10 * * * * * *".into(),
            skippable: true,
        };

        // 4 - 通过 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,    // 金额
            thread_id,                      // ID
            vec![toggle_ix.into()],         // 指令
            account_trigger,                // 触发器
        )?;

        // 5 - 初始化开关
        switch.switch_state = true;

        Ok(())
    }

这里的内容很多,所以让我们逐步解析。

  1. 我们获取初始化 SwitchThread 账户所需的所有账户。
  2. 我们准备一个指令进行自动化。该指令将在 Thread 账户的 Switch 账户状态发生变化时执行。它将调用我们程序上的 response 函数。如果你在 Solana 上尚未做太多构建,这可能看起来很奇怪。要理解指令,关键在于不得不传三个部分:程序 ID、指令将使用的账户以及指令将传递给程序的数据(在这种情况下,没有数据是必需的)。
  3. 我们定义了两个触发器进行实践,尽管你为创建的任何线程只能使用一个。第一个是账户触发器,当 Switch 账户更改时,这个指令将被执行(我们指定触发器查看特定账户在我们希望跟踪的特定字节时,换句话说,switch_state 布尔值)。第二个是 cron 触发器,它将每 10 秒执行该指令。
  4. 我们创建对 Clockwork 线程程序的跨程序调用 (CPI),以创建 Thread 账户。

    • 首先,我们使用 ctx.bumps.get("thread_authority").unwrap() 获取 Thread 账户权限的 bump。
    • 然后,我们使用 CpiContext::new_with_signer 定义我们的上下文以包含 clockwork_program、我们所需的账户(payersystem_programthreadauthority),和 thread_authority 的种子,它将签署交易。
    • 最后,我们传递生成新线程所需的数据:
      • 用于为线程提供种子的 Lamports 数(金额)(这将用于覆盖交易和 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('未能获取签名状态');
        }

        const status = statuses[0];

        if (status === null) {
            await new Promise(resolve => setTimeout(resolve, pollInterval));
            continue;
        }

        if (status.err) {
            throw new Error(`交易失败: ${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(`交易确认超时,超时为 ${timeout}ms`);
}

describe("Clockwork Demo", async () => {
  // 配置 Anchor 和 Clockwork 提供程序
  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("初始化程序测试:", program.programId.toBase58());
  console.log(`https://explorer.solana.com/address/${program.programId.toBase58()}?cluster=devnet`);

  // 生成 PDAs
  const [switchPda] = PublicKey.findProgramAddressSync(
    [anchor.utils.bytes.utf8.encode("switch")], // 👈 确保与程序中的匹配
    program.programId
  );
  const threadId = "thread-test-"+ new Date().getTime() / 1000;
  const [threadAuthority] = PublicKey.findProgramAddressSync(
    [anchor.utils.bytes.utf8.encode("authority")], // 👈 确保与程序中的匹配
    program.programId
  );
  const [threadAddress, threadBump] = clockworkProvider.getThreadPDA(threadAuthority, threadId);

  // 给支付者提供资金
  beforeEach(async () => {
    await connection.requestAirdrop(payer, LAMPORTS_PER_SOL * 100);
  });
  it("初始化线程和开关", async () => {
    try {
      // 生成并确认初始化交易
      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, "交易结果存在错误");

      // 检查线程和开关账户是否被创建
      const switchAccount = await program.account.switch.fetch(switchPda);
      assert.ok(switchAccount.switchState, "开关状态应为 true");
    } catch (error) {
      assert.fail(`发生错误: ${error.message}`);
    }
  });
  it("切换开关 5 次", async () => {
    let slot = 0;
    for (let i = 0; i < 5; i++) {
      try {
        // 生成并确认切换
        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, "交易结果存在错误");

        // 等待 1 秒后再检查线程
        await new Promise(resolve => setTimeout(resolve, 1000));

        // 检查线程是否被触发
        const execContext = (await clockworkProvider.getThreadAccount(threadAddress)).execContext;
        if (execContext.lastExecAt) {
          console.log(`第 ${i+1} 次循环的最后线程触发时间: `, execContext.lastExecAt.toNumber());
          assert.ok(execContext.lastExecAt.toNumber() > slot, "线程应已触发");
          slot = execContext.lastExecAt.toNumber();
        }

        // 在下一次切换之前等待 1 秒
        await new Promise(resolve => setTimeout(resolve, 1000));
      } catch (error) {
        assert.fail(`发生错误: ${error.message}`);
      }
    }
  });
});

这里的内容很多,我们尽量对其进行解析:

  1. 导入必要的依赖项
  2. 配置 Anchor 和 Clockwork 提供程序
  3. SwitchThread 账户生成 PDAs
  4. 通过调用 requestAirdrop 给支付者资金
  5. 创建一个测试,用于初始化 SwitchThread 账户

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

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

最终组件有效地改变了 Switch 账户的状态,这会触发 Thread 账户。然后我们检查线程的最后执行时间以确保它已被触发。很酷吧?

现在我们有了测试,让我们运行它们! 在终端中运行以下命令:

anchor test --skip-deploy --skip-build

我们跳过构建和部署步骤,因为我们已经完成了。

如果你的程序工作正常,你应该看到如下内容:

初始化程序测试: 9jA8jEj1opkV2zFryS6tK1jrGLdmVwtUp86TFc7Frtzs
https://explorer.solana.com/address/9jA8jEj1opkV2zFryS6tK1jrGLdmVwtUp86TFc7Frtzs?cluster=devnet

  ✔ 初始化线程和开关 (141ms)
第 1 次循环的最后线程触发时间:  84
第 2 次循环的最后线程触发时间:  89
第 3 次循环的最后线程触发时间:  94
第 4 次循环的最后线程触发时间:  99
第 5 次循环的最后线程触发时间:  104
  ✔ 切换开关 5 次 (11981ms)

  2 通过的 (12s)

✨  用时 13.05s 完成。

干得好!除了在终端中看到成功的测试之外,你还可以在 Solana Explorer 中查看它们(我们在终端中记录了程序的链接)。

额外奖励 - Cron 作业

想继续吗?尝试将你的触发器从账户更改为 cron 作业。如果你还记得,在我们的程序 lib.rs 中,我们创建了一个未使用的 _cron_trigger 变量,设置了每 10 秒运行一次的计划。你能修改程序以使用这个触发器而不是 Switch 账户吗?这里有一些提示来帮助你:

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

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

时间到!

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

  • 自动化 DeFi
  • 高级游戏
  • 分析工具
  • 付款和订阅
  • 以及更多!

如果你遇到困难、有问题,或者只是想讨论一下,请在 DiscordTwitter 上联系我!

我们 ❤️ 反馈!

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

资源

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

0 条评论

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