Solana智能合约开发:HelloWorld 合约

Solong
18 min readDec 29, 2020

要进行Solana的合约开发,这里假设你已经按照前面文章的介绍,搭建好了Solana 1.4.x的 开发环境(安装好了rust和solana1.4.x命令行工具)。然后我们来看一个HelloWorld的合约 以及DApp的实现。在官方的Demo中也有个HelloWorld的例子,但是一来这个例子不是基于 新的Solana开发工具链,通过npm命令隐藏了开发工具链的基本使用。 二来这个例子不能很好的展示Solana合约开发过程中的关键概念,类似Account 是什么?Instruction的序列化和解析,Account内容的存储和读取。因此我们这里设计了一个新的 HelloWorld。

这个HelloWorld的功能如下:

DApp调用一个名为printhello的接口,该接口发送一个Transaction 到链上,该Transaction包含两条Instruction:一条创建一个Account文件,一条将 helloworld的参数内容记录在这个Account文件中。执行成功后,返回这个Account文件的地址。 DApp可以通过这个地址,查询里面存放的内容。

逻辑上更像是一个print "hello wolrd",只是他是将内容“print”到链上记录下来。

创建工程

首先我们新建合约目录,这里假设为”onchain_program”目录:

cargo new  onchain_program --lib
Created library `onchain_program` package

使用cargo创建工程,这里 — lib指定为库文件。

然后我们修改”Cargo.toml”:

[package]
name = "helloworld"
version = "0.1.0"
authors = ["CZ <cz.theng@gmail.com>"]
edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html[features]
no-entrypoint = []
[dependencies]
solana-program = "1.4.8"
[lib]
crate-type = ["cdylib", "lib"]

主要修改有:

  1. name 指定了生成的二进制文件的名字
  2. features 里面增加了”no-entrypoint”特性
  3. dependencies里面增加了Solana合约SDK:solana-program 的依赖
  4. 通过crate-type指定生成的库文件类型

同时在Cargo.toml同级目录创建文件”Xargo.toml” 用于跨平台生成BPF目标文件格式。内容为:

[target.bpfel-unknown-unknown.dependencies.std]
features = []

然后修改src/lib.rs内容为:

#![deny(missing_docs)]
#![forbid(unsafe_code)]
//! a helloworld onchain program// Export current sdk types for downstream users building with a different sdk version
pub use solana_program

其实就仅仅加了条注释而已。然后导入了Solana合约的SDK。

然后就可以再这个目录下进行编译了:

cargo build-bpf

这个工具在前面的工具链中已经有介绍了,输出为:

onchain_program$ cargo build-bpf
BPF SDK: /home/ubuntu/solana/solana-release/bin/sdk/bpf
Running: /home/ubuntu/solana/solana-release/bin/sdk/bpf/rust/xargo-build.sh
Finished release [optimized] target(s) in 0.10s
To deploy this program:
$ solana deploy xxx/onchain_program/target/deploy/helloworld.so

这里表示编译好的合约程序在onchain_program/target/deploy/helloworld.so,用solana的工具就可以发布了。

合约结构

Solana的合约程序,其实主要干三个事情:

  1. 解析由runtime传过来的instruction
  2. 执行instruction对应的逻辑
  3. 将执行结果中需要落地的部分,pack打包输出到指定的Account文件

根据这个逻辑结构,我们依次创建如下几个文件:

同时为了方便程序书写,我们创建:

  • error.rs: 出错处理,定义各种错误
  • entrypoint.rs : 结合“entrypoint”特性,封装合约入口

这个时候我们的工程看起来如下这样:

.
├── Cargo.lock
├── Cargo.toml
├── src
│ ├── entrypoint.rs
│ ├── error.rs
│ ├── instruction.rs
│ ├── lib.rs
│ ├── processsor.rs
│ └── state.rs
└── Xargo.toml

合约实现

1. entrypoint

entrypoint 是所有合约的入口,是一个处理函数,原型为:

entrypoint!(process_instruction);
fn process_instruction(
program_id: &Pubkey,
accounts: &[AccountInfo],
instruction_data: &[u8],
) -> ProgramResult

通过entrypoint特性指定入口函数的函数名,而函数定义为接受三个参数并返回ProgramResult类型的 函数,三个参数依次是合约的地址program_id、instruction里面keys经过runtime解析得到的账号信息 数组accounts以及instruction里面的data部分。

这里将对instrcution封装到了process里面,因此这里直接调用process的函数:

entrypoint!(process_instruction);
fn process_instruction(
program_id: &Pubkey,
accounts: &[AccountInfo],
instruction_data: &[u8],
) -> ProgramResult {
if let Err(error) = Processor::process(program_id, accounts, instruction_data) {
// catch the error so we can print it
error.print::<HelloWorldError>();
return Err(error);
}
Ok(())
}

注意这里增加了错误时候的捕捉:

error.print::<RegistryError>();

当出错的时候,会在日志和RPC调用里面返回出错信息。这里HelloWorldError就是error.rs里面定义的程序错误。

2.error

error的定义主要是用于收敛程序中的错误,并给出具体的错误消息,如果对应的错误出现,在RPC调用时,会明确给出 错误提示:

Custom Error: 0x02

对应错误的枚举值。

/// Errors that may be returned by the hello-world program.
#[derive(Clone, Debug, Eq, Error, FromPrimitive, PartialEq)]
pub enum HelloWorldError {
/// Invalid instruction
#[error("Invalid instruction")]
InvalidInstruction,
}
impl From<HelloWorldError> for ProgramError {
fn from(e: HelloWorldError) -> Self {
ProgramError::Custom(e as u32)
}
}
impl<T> DecodeError<T> for HelloWorldError {
fn type_of() -> &'static str {
"HelloWorldError"
}
}
impl PrintProgramError for HelloWorldError {
fn print<E>(&self)
where
E: 'static + std::error::Error + DecodeError<E> + PrintProgramError + FromPrimitive,
{
match self {
RegistryError::InvalidInstruction => info!("Invalid instruction"),
}
}
}

这里为了使得Error可以打印,用了几个辅助库,所以在Cargo.toml的dependence里面增加:

num-derive = "0.3"
thiserror = "1.0"
num-traits = "0.2"
arrayref = "0.3.6"
num_enum = "0.5.1

HelloWorldError即为定义的错误枚举,然后为枚举实现了”From”、”DecodeError” 以及”PrintProgramError” 等traits。

3. instruction

这里我们定义了两个指令:

/// Instructions supported by the hello-world program.
#[repr(C)]
#[derive(Clone, Debug, PartialEq)]
pub enum HelloWorldInstruction {
/// Hello print hello to an Account file
Hello{
/// message for hello
message: String,
},
/// Erase free the hello account
Erase ,
}

一个是带有一个String类型参数的 “Hello” 另一个是删除文件的不带参数的 “Erase”。定义好结构后,我们需要为其 书写反序列化函数,对于instruction真正工作的其实只有反序列化函数,比如这里叫unpack,而序列化是在客户端 请求做的,因此pack函数不是必须的,但是如果使用单元测试的时候,可能需要通过pack来构建hook内容。

对于序列化的格式,我们采用了固定长度的二进制堆叠法:

+-----------------------------------+
Hello: | 0 | message |
+-----------------------------------+
+--------+
Erase: | 1 |
+--------+

如上图,第一个字节表示消息的类型,对于Hello消息,消息内容紧随其后。

所以我们的解析代码可以这样写:

impl HelloWorldInstruction {
/// Unpacks a byte buffer into a [HelloWorldInstruction](enum.HelloWorldInstruction.html).
pub fn unpack(input: &[u8]) -> Result<Self, ProgramError> {
use HelloWorldError::InvalidInstruction;
let (&tag, rest) = input.split_first().ok_or(InvalidInstruction)?;
Ok(match tag { //HelloWorld
0 => {
let message= String::from(from_utf8(rest).unwrap());
Self::Hello{
message,
}
},
1 => Self::Erase,
_ => return Err(HelloWorldError::InvalidInstruction.into()),
})
}

4. state

state是我们用来将内容存储到对应的文件时,存储格式的定义,类似一个ORM或者所谓的MVC中Model层。 因此我首先定义我们的Model:

/// HelloWorld data.
#[repr(C)]
#[derive(Clone, Debug, Default, PartialEq)]
pub struct HelloWorldState {
/// account
pub account_key: Pubkey,
/// message
pub message: String
}

这里定义了谁:account 说了什么:message。然后定义了Model层操作文件的方法,这里通过Solana的SDK 提供的Pack trate来实现其序列化和反序列化,

impl Pack for HelloWorldState {
const LEN: usize = 32+1+256; // max hello message's length is 256
fn unpack_from_slice(src: &[u8]) -> Result<Self, ProgramError> {
...
}
fn pack_into_slice(&self, dst: &mut [u8]) {
...
}
}

LEN定义了Account所占用的总大小。注意,当前Solana上,Account仅可以初始化一次长度信息,创建后不可 更改。

然后实现unpack_from_slice,从文件中解析Model:

fn unpack_from_slice(src: &[u8]) -> Result<Self, ProgramError> {
let src = array_ref![src, 0, 289];
let (account_key_buf, message_len_buf, message_buf) = array_refs![src, 32, 1, 256];
let account_key = Pubkey::new_from_array(*account_key_buf);
let message_len = message_len_buf[0] as u8;
let (msg_buf, _rest) = message_buf.split_at(message_len.into());
let message = String::from(from_utf8(msg_buf).unwrap()) ;
Ok(HelloWorldState {
account_key,
message
})
}

这里首先通过array_ref得到一个array,然后通过array_refs指定三个成员 的内容,这里我们在序列化文件内容 时,采用和Instruction一样的二进制序列化方法,对于Pubkey其固定为32个字节。对于Message,其长度 我们约定小于256,这样用一个字节表示长度,后面256个字节表示内容(256不一定全部用完,但是任然分配空间).

+-------------------------------------------+
| key |l|message |
+-------------------------------------------+

读出对应的buffer内容后,进行类型转换。

同样的,对于序列化的使用使用:

fn pack_into_slice(&self, dst: &mut [u8]) {
msg!("pack into slice");
let dst = array_mut_ref![dst, 0, 289];
let (
account_key_buf,
message_len_buf,
message_buf,
) = mut_array_refs![dst, 32, 1, 256];
msg!("pack into slice key");
account_key_buf.copy_from_slice(self.account_key.as_ref());
msg!("pack into slice len");
message_len_buf[0] = self.message.len() as u8;
msg!(&format!("pack into slice msg:{}", self.message));
let (msg_buf, _rest) = message_buf.split_at_mut(self.message.len());
msg_buf.copy_from_slice(self.message.as_bytes());
msg!("pack into slice out");
}

这里采用mut_array_refs预先给几个要存储的元素分配好地址,然后使用copy_from_slice复制32字节 的key,用as u8转换长度,copy_from_slice copy字符串内容。

5. process

最关键的处理程序来了。首先我们将runtime给过来要处理的instruction进行反序列化操作:

/// Processes an [Instruction](enum.Instruction.html).
pub fn process(_program_id: &Pubkey, accounts: &[AccountInfo], input: &[u8]) -> ProgramResult {
let instruction = HelloWorldInstruction::unpack(input)?;

然后根据前面介绍的反序列化后的instruction来进行分别的处理:

match instruction {
HelloWorldInstruction::Hello {
message,
} => {
msg!(&format!("hello-world: HelloWorld msg:{}", message));
Self::process_hello(accounts, message)
}
HelloWorldInstruction::Erase=>{
msg!("hello-world: Erase");
Self::process_erase(accounts)
}
}

对于Hello,我们的处理就是将消息内容和谁发的信息,进行记录:

/// Processes an [Hello](enum.HelloWorldInstruction.html) instruction.
fn process_hello(
accounts: &[AccountInfo],
message: String,
) -> ProgramResult {
let account_info_iter = &mut accounts.iter();
let client_info = next_account_info(account_info_iter)?;
let message_info = next_account_info(account_info_iter)?;
// check permission
if !client_info.is_signer || !message_info.is_signer{
return Err(ProgramError::MissingRequiredSignature);
}

msg!("before unpack hello");
let mut state = HelloWorldState::unpack_unchecked(&message_info.data.borrow())?;
msg!("after unpack hello");
state.account_key = *client_info.key;
state.message = message;

msg!("before pack hello");
HelloWorldState::pack(state, &mut message_info.data.borrow_mut())?;
msg!("after pack hello");
Ok(())
}

用户传递instruction里面的keys数组就对应这里的accounts数组,用户将其创建的消息账号通过这个数组 传递过来,我们通过next_account_info进行获取,分别货期了用户账号client_info和消息账号message_info 。这里我们通过判断!client_info.is_signer来判断,用户构建transaction是否用了自己的进行签名, 如果是的话,runtime会进行校验,因此我们只要判断,他是否是被校验的单元就好了,无需我们自己去调用鉴权接口。

接着就是Model里面先解析Model对象,然后进行修改后,在写回Model对象。

总结

上面通过一个真实的HelloWorld程序介绍了一个Solana合约的工程结构,以及一个推荐的组织代码的文件目录 布局的方式,并通过Instruction->Process->State介绍了从客户端传递Transaction到处理逻辑后将结果 写入Account文件的过程。具体Demo可以参见SolanaHelloWorld

参考

  1. Solana Docs
  2. solana-program-library
  3. example-helloworld

--

--