新消息:Helius 收购 Light Protocol
使用 Pinocchio 构建 Solana 智能合约
博客/开发

如何使用 Pinocchio 构建 Solana 程序

领先的 Solana 开发机构X 上的 Exo TechnologiesLinkedIn 上的 Exo Technologies
Exo Technologies 联合创始人X 上的 Taylor JohnsonLinkedIn 上的 Taylor Johnson
阅读需 12 分钟

Pinocchio 是一个经过高度优化且零依赖的库,可用于构建原生 Solana 程序。Pinocchio 由 Solana Agave 客户端的核心开发团队 Anza 创建。 

Exo Tech 是领先的 Solana 开发团队,也是 Pinocchio 最早的采用者之一。通过客户项目,我们已经使用 Pinocchio 开发了多个生产级程序,并为 SDK 贡献代码以补充缺失的功能。 

本文将深入介绍如何使用 Pinocchio 构建程序,并探讨其优势与取舍。我们的目标是帮助开发者掌握必要知识,判断 Pinocchio 是否适合自己的程序。不过需要注意,Pinocchio 优先考虑优化而非开发者体验,因此并不适合初学者。

Pinocchio 库是什么?

Pinocchio 库 可替代 solana-program crate,并通过广泛使用 zero-copy 类型来优化程序执行。zero-copy 意味着读写数据时无需将数据复制到单独的内存地址,从而节省计算资源(在 Solana 中称为 CU)。

该库零依赖,并且是“no_std”的。Rust 的 std crate 提供访问操作系统资源的常用方式和运行时,但由于 Solana Virtual Machine (SVM) 本身就是运行时,因此不需要这些额外开销。

为什么 Pinocchio 的性能优于 solana-program?

每个 Solana 程序都需要一个入口点,以便运行时调用并执行程序。solana-program 库公开了 entrypoint! 宏,它会反序列化程序输入、设置堆分配器并创建 panic 处理程序。

代码
macro_rules! entrypoint {
    ($process_instruction:ident) => {
        /// # Safety
        #[no_mangle]
        pub unsafe extern "C" fn entrypoint(input: *mut u8) -> u64 {
            let (program_id, accounts, instruction_data) =
                unsafe { $crate::entrypoint::deserialize(input) };
            match $process_instruction(&program_id, &accounts, &instruction_data) {
                Ok(()) => $crate::entrypoint::SUCCESS,
                Err(error) => error.into(),
            }
        }
        $crate::custom_heap_default!();
        $crate::custom_panic_default!();
    };
}

Pinocchio 导出了三个入口点宏。

对于从 solana-program 迁移的开发者,entrypoint! 宏的工作方式基本相同:反序列化程序输入,并设置分配器和处理程序。 

不过,另外两个宏将入口点与堆分配器及 panic 处理程序的设置解耦,让开发者可以在执行程序逻辑之前省略或优化这些步骤,从而获得更大的控制权。 

program_entrypoint! 以类似 solana-program 的方式反序列化程序输入,而 lazy_program_entrypoint! 只封装输入缓冲区,并将处理工作交给程序,从而提供更强的计算控制能力。 

由于这些宏不会设置堆分配器或 panic 处理程序,Pinocchio 库提供了可供开发者使用的默认宏。

另外,如果程序确定永远不需要堆内存,no_allocator! 可以跳过内存分配器设置,从而节省计算单元(CU)。

Pinocchio 入口点如何以不同方式反序列化 Solana 程序输入?

前面简单提到过,solana-program 和 Pinocchio 的入口点都会反序列化程序输入。但理解两者反序列化方式的差异非常重要,因为大量 CU 节省正是来自这里。 

乍看之下,传递给程序指令处理程序的反序列化输入完全相同:

代码
/// solana-program and pinocchio both look the same
process_instruction(
         program_id: &Pubkey,
         accounts: &[AccountInfo],
         instruction_data: &[u8],
     ) -> ProgramResult

关键区别在于 AccountInfo 的实现。 

solana-program 会将数据写入拥有这些数据的 AccountInfo 结构体,而 Pinocchio 的 AccountInfo 结构体本身只是指向表示该账户的底层输入数据的指针。这样可以减少需要复制的数据量,节省大量 CU。

Pinocchio 如何帮助开发者优化 CU?

由于指令处理器接收的是指针引用,使用 Pinocchio 库的开发者会发现,他们的逻辑几乎从不拥有正在处理的数据。 

尝试访问 AccountInfo 中的值时,很容易观察到这一点。使用 key() 方法读取账户公钥时,会返回对 Pubkey 的引用。这既降低了程序执行期间读取账户信息的成本,也降低了修改账户数据的成本。

Pinocchio CU 优化示例:P-token

p-token 程序就是持续利用零拷贝进行优化的绝佳示例。

该程序旨在替代标准 SPL Token Program,并使用 Pinocchio 大幅减少每笔交易消耗的计算单元。

你很快就会注意到,所有状态都通过指针访问。

代码不会反序列化代币账户,而是检查 AccountInfo 中的数据,然后返回一个指针。

每个属性都通过函数访问,所有非原始类型的值都会返回引用,从而保持零拷贝。 

要进一步了解这种方式为何能大幅降低 CU 使用量,请阅读这篇有关 CU 优化的文章。

Pinocchio 与 Anchor 对比

Anchor 是一个非常流行且约定明确的 Solana 程序开发框架。它不像 Pinocchio 那样提供公开 AccountInfo 等底层结构的逻辑,因此通常被视为更高层的框架。

Anchor 依赖前面提到的 solana-program crate,并通过公开 trait 和宏来简化程序开发流程。Anchor 提供指令判别器模式和账户反序列化逻辑。该反序列化逻辑依赖 Borsh,而 Borsh 并非零拷贝,因此需要将数据复制到另一个内存地址。 

Anchor 提供的便利性可以加快 Solana 程序开发,但代价是消耗更多 CU。

另一方面,当开发者需要精细控制计算资源的使用时,可以用 Pinocchio 库替代 solana-program。它完全不限定开发方式,允许开发者以任何合适的方式组织程序。每个 Pinocchio 项目的布局可能截然不同,而 Anchor 项目则具有明确的结构。 

Pinocchio 库不处理任何客户端绑定或实现。相比之下,Anchor 原生支持生成 IDL,客户端可以利用 IDL 与程序交互。

使用 Pinocchio 的开发者必须自行编写这些内容,或使用 Shank 和 Codama 等其他工具。我们将在下文的使用 Pinocchio 构建程序的配套工具一节中介绍这些工具。

Pinocchio 与 Steel 对比

Steel 是另一个用于编写 Solana 程序的框架。Steel 目前构建在 solana-program 之上,提供宏、函数和模式,让开发者可以轻松编写安全且富有表现力的程序。

Steel 明确的约定使代码易于阅读,同时保持模块化。与必须全盘采用的 Anchor 不同,开发者可以只使用自己需要的 Steel 组件。

Steel 的 account! 宏使用 bytemuck 解析账户结构,而 Pinocchio 完全不处理账户解析。Steel 还提供可链式调用的解析器和断言,让添加自定义验证变得简单。Pinocchio 并未内置此类模式,开发者需要自行编写验证模式。

不过,对于 System Program 和 Token Program 等常见的跨程序调用(CPI),Pinocchio 和 Steel 都提供了简化调用的模式。

Pinocchio 经过高度优化,但将所有细节都交给开发者。Steel 则是对 solana-program 库的模块化封装,旨在改善开发者体验。

如何使用 Pinocchio 创建代币

为了演示使用 Pinocchio 编写的程序,我们将重写 Solana 开发者示例中的创建代币程序。

这是一个简单的程序,只有一条指令。它会创建一个 Token2022 代币 mint,并使用 Metadata 代币扩展存储代币信息。元数据将通过指令数据提供,其中包含名称、符号和 uri。

1. 定义入口点

首先定义程序的入口点。

由于我们希望使用 Pinocchio 的默认分配器和 panic 处理机制,因此这里使用完整的入口点宏。

代码
entrypoint!(process_instruction);

fn process_instruction(
   _program_id: &Pubkey,
   accounts: &[AccountInfo],
   instruction_data: &[u8],
) -> ProgramResult {
   Ok(())
}

2. 定义指令数据结构

接下来,按照其他示例程序定义指令数据的结构。为了节省开发时间,我们将使用 Borsh 进行反序列化,并把更优的反序列化方法留到另一篇文章中讨论。

代码
#[derive(BorshDeserialize, Debug)]
pub struct CreateTokenArgs {
   pub name: String,
   pub symbol: String,
   pub uri: String,
   pub decimals: u8,
}

3. 解析账户和指令数据

现在开始编写指令处理器中的逻辑。

首先,我们必须从账户列表中解构账户,并将指令数据反序列化到 CreateTokenArgs 中。

代码
let [mint_account, mint_authority, payer, token_program, _system_program] = accounts else {
       return Err(ProgramError::NotEnoughAccountKeys);
   };

   let args = CreateTokenArgs::try_from_slice(instruction_data)
       .map_err(|_| ProgramError::InvalidInstructionData)?;

4. 创建 Token2022 Mint 账户

解析账户和指令数据后,我们调用 System program 的 CreateAccount 指令。

下面使用的是 `pinocchio_system crate as it makes it very convenient to CPI by setting values of the struct and calling invoke 中的 CreateAccount 结构体。

与创建普通 SPL Token mint 不同,我们必须确定所用代币扩展需要的额外空间。

Metadata Pointer 扩展的大小是固定的,而 Token Metadata 扩展的大小必须根据传入的参数动态计算。

代码
 /// [4 (extension discriminator) + 32 (update_authority) + 32 (metadata)]
   const METADATA_POINTER_SIZE: usize = 4 + 32 + 32;
   /// [4 (extension discriminator) + 32 (update_authority) + 32 (mint) + 4 (size of name ) + 4 (size of symbol) + 4 (size of uri) + 4 (size of additional_metadata)]
   const METADATA_EXTENSION_BASE_SIZE: usize = 4 + 32 + 32 + 4 + 4 + 4 + 4;
   /// Padding used so that Mint and Account extensions start at the same index
   const EXTENSIONS_PADDING_AND_OFFSET: usize = 84;

   /* within `process_instruction` */
   let extension_size = METADATA_POINTER_SIZE
       + METADATA_EXTENSION_BASE_SIZE
       + args.name.len()
       + args.symbol.len()
       + args.uri.len();
   let total_mint_size = Mint::LEN + EXTENSIONS_PADDING_AND_OFFSET + extension_size;

   let rent = Rent::get()?;
   // Create the account for the Mint
   CreateAccount {
       from: payer,
       to: mint_account,
       owner: token2022_program.key(),
       lamports: rent.minimum_balance(Mint::LEN),
       space: Mint::LEN as u64,
   }
   .invoke()?;

调用 CreateAccount 后,SystemProgram 会将 Token2022 程序注册为 mint 账户的所有者。

5. 初始化扩展、账户和元数据值

接下来,我们必须设置账户数据:初始化 Metadata Pointer 扩展、使用 Token2022 程序初始化 Mint 账户,并初始化程序通过参数接收的元数据值。 

以下 CPI 来自正在积极开发的 pinocchio_token crate 分支。需要注意的是,由于 Token2022 功能计划从 SPL Token crate 中拆分出来,这段代码可能很快就会过时。

代码
// Initialize MetadataPointer extension pointing to the Mint account
   InitializeMetadataPointer {
       mint: mint_account,
       authority: Some(*payer.key()),
       metadata_address: Some(*mint_account.key()),
   }
   .invoke()?;

   // Now initialize that account as a Token2022 Mint
   InitializeMint2 {
       mint: mint_account,
       decimals: args.decimals,
       mint_authority: mint_authority.key(),
       freeze_authority: None,
   }
   .invoke(TokenProgramVariant::Token2022)?;

   // Set the metadata within the Mint account
   InitializeTokenMetadata {
       metadata: mint_account,
       update_authority: payer,
       mint: mint_account,
       mint_authority: payer,
       name: &args.name,
       symbol: &args.symbol,
       uri: &args.uri,
   }
   .invoke()?;

完成了! 

现在,我们已经使用 Pinocchio 编写了一个基于 Token2022、包含自有元数据的代币 mint。

这段代码仍有进一步优化的空间,但我们希望它能帮助你了解如何使用 Pinocchio 编写程序。

使用 Pinocchio 构建程序的配套工具

Pinocchio 的专用工具目前不多,但正在持续增加。

使用 Bytemuck 对账户进行序列化与反序列化

Pinocchio 程序的开发者必须自行实现账户序列化与反序列化。手动完成这一过程既繁琐又容易出错。Bytemuck 是一个优秀的库,可以轻松将字节数组作为结构体进行读写。它限制了需要复制到内存的数据量,因此优化效果相当不错。

处理大小不固定的账户时,Borsh 是另一种解决方案。不过它对计算资源不够友好,这也是人们选择 Pinocchio 而非 Anchor 的原因之一。

使用 Shank 生成 IDL

Pinocchio 是一个库,因此不像 Anchor 那样内置 IDL 生成功能。IDL(接口定义语言)是一个 JSON 文件,用于定义 Solana 程序的公共接口,包括指令、账户结构和错误代码,从而实现标准化交互并简化客户端开发。

生成 IDL 时,我们建议使用 Shank。这个 crate 让开发者可以非常轻松地为代码添加注解,并使用 CLI 生成有效的 IDL。在结构体的 derive 语句中添加 ShankAccount 宏,表示该结构体是一个应当支持序列化与反序列化的账户。运行 shank CLI 后,该结构将作为有类型的账户出现在 IDL 中,随后可用于生成客户端。

另一个重要的宏是用于程序指令枚举的 ShankInstruction。它允许使用 #[account] 属性,指定相应指令的账户列表中每个账户的索引和权限。

有关这些实用代码注解的更多信息,请参阅 shank-macro 仓库。它们可以简化非 Anchor 程序的 IDL 生成。

使用 Codama 生成客户端

获得 IDL 后,就可以使用 Codama 轻松生成客户端。如果生成的代码无法满足你的需求,则需要手动编写客户端。

在 Exo Tech,我们创建了一个 Pinocchio 项目模板,帮助团队快速搭建 Solana 程序仓库。欢迎试用,也欢迎提交 pull request 来改进它!

Pinocchio 的未来

尽管 Pinocchio 旨在直接替代 solana-program,但两者的功能目前尚未完全对等。部分 sysvar 尚不受支持,非核心 crate 也未得到完整支持,甚至尚不存在。例如,Pinocchio Token program crate 不支持多个签名者。Token2022 目前也不受支持,但相关功能正在开发中。

使用 Pinocchio 的一个较大缺点是,为其他 Solana 程序开发的所有 SDK 都使用 solana-program crate。这意味着每个 SDK 都需要拥有 AccountInfo 或所传递数据的所有权,因此很难与使用 Pinocchio 开发的程序互操作。 

与第三方程序集成时,通常必须为每条指令编写自定义 CPI 逻辑。Codama 等代码生成器最终或许能解决这个问题,但目前还做不到。

需要注意的是,Pinocchio 仍在积极开发中,且尚未经过审计。社区仍在努力将其余 sysvar 加入 SDK,并改善对 Token 和 Token2022 等重要 SPL 程序的支持。

如何为 Pinocchio 做贡献

Pinocchio 还有许多容易入手的贡献机会。

目前有一些开放的 issue 和现有的 pull request 需要更多支持。你可以加入讨论,也可以直接提交 pull request 供维护者审核!

总结

与以往的解决方案相比,Pinocchio 是一个性能显著更高的 Solana 程序开发库。它让开发者可以更灵活地控制程序入口点,并使用 zero-copy 访问程序输入,从而帮助减少 CU 使用量。不过,它仍是一个较新的库,功能尚不完整。截至本文撰写时,该库尚未经过审计,请谨慎使用。

评估是否使用 Pinocchio 时,需要认真权衡它与其他库和框架之间的取舍。

Anchor 等约定明确的框架可以加快程序开发速度,也更容易维护,因此在快速上市至关重要时是很好的选择。

当产品趋于稳定并开始处理大量交易时,使用 Pinocchio 之类的库优化 Solana 程序可能更合适。

其他资源

如需了解更多信息,请观看 Febo 在 Solana Accelerate 2025 上的演讲,并浏览以下学习资源:

订阅 Helius

及时了解 Solana 开发的最新动态,并在我们发布新内容时收到更新

放大图片