Solana智能合约开发:SDK的陷阱

Solong
13 min readDec 31, 2020

如果你有其他链的开发经验,比如ETH、EOS等。那么在使用Solana的SDK来进行开发的时候,很可能 会被一些与通常行为不一致的表现带到陷阱里面去。这里列举了一些注意点。

Account长度固定

Account是Solana中的存储对象,类似EOS里面的内存。但是Solana中的Account除了在初始化的时候 可以指定长度外,其长度就不可变了。如果对Solana的SDK熟悉的话,会看到SDK中有类似这样的接口:

pub fn allocate(pubkey: &Pubkey, space: u64) -> Instruction {
let account_metas = vec![AccountMeta::new(*pubkey, true)];
Instruction::new(
system_program::id(),
&SystemInstruction::Allocate { space },
account_metas,
)
}

根据Solana开发的计划,未来会提供修改Account长度的功能。 参见Discrod 讨论

对于一般程序的理解,文件对象都会有增删改查的功能,比如最开始可能不知道要用多少来存储,但是随着 用户的增加,要存储的内容越来越多,所以这个功能我们还是等待官方的开发吧。

构造AccountInfo

AccountInfo结构的定义为:

pub struct AccountInfo<'a> {
/// Public key of the account
pub key: &'a Pubkey,
/// Was the transaction signed by this account's public key?
pub is_signer: bool,
/// Is the account writable?
pub is_writable: bool,
/// The lamports in the account. Modifiable by programs.
pub lamports: Rc<RefCell<&'a mut u64>>,
/// The data held in this account. Modifiable by programs.
pub data: Rc<RefCell<&'a mut [u8]>>,
/// Program that owns this account
pub owner: &'a Pubkey,
/// This account's data contains a loaded program (and is now read-only)
pub executable: bool,
/// The epoch at which this account will next owe rent
pub rent_epoch: Epoch,
}

这个结构可以表示链上的任何一个账号,比如一个SOL账号,或者一个SPL如SRM的地址,以及我们介绍的 表示一般文件的Account的地址。当我们有一个类似”HSfwVfB7RUF1SKCd4yrz8KZp7TU262Y5BeZZN1tdCTVk”
地址的时候,我们要如何得到上面这样的结构呢?

如果看过SDK的接口的话,可能你会想到各种方法,比如:

AccountInfo::new
AccountInfo::From

等其他方式。

但是很不幸的是,在合约里面,所有这些方法都是不起作用的。唯一的方式是通过RPC接口,传入一个地址 给到节点,然后节点上的runtime会将这个地址对应的信息加载进来。也就是将instruction中的keys 对象,转换成AccountInfo数组。

所以,当程序需要给其他账号转账时,此时我们可以在程序中调用Token合约的转账方法:

invoke(
&system_instruction::transfer(
src_info.key,
dst_info.key,
amount,
),
&[
src_info.clone(),
dst_info.clone(),
system_program_info.clone(),
],
)?;

这里我们必须要用到三个AccountInfo:

  • src_info: 源账号
  • dst_info: 目标账号
  • system_program_info: 系统程序账号

而这三个账号你是无法通过合约代码来构造的,唯有通过客户端的RPC接口传递过来。

因此对于类似这样的需求,是无法仅通过合约来实现:

从Account存储的玩家账号中,选取一个给他发SOL

而必须由一个机器人,先执行一个指令,触发合约的选择算法,把结果存起来,然后在弄个指令,像这个 结果的玩家发送SOL。

程序账号/文件账号自身不具备SOL记账功能

假设我们的产品给我们提供了一个需求,内容是:每个玩家向合约转一个SOL,然后合约从这些玩家 中选取一个幸运儿,将所有的SOL都传给他。

想想,对于这样的需求,大概需要几个Account?

一般的想法是一个Account记录这些玩家就可以了,我们通过程序账号来收SOL,然后在用程序账号将SOL 转给幸运儿。

恭喜你,又错了。这里我们需要理解一些SOL,SOL其实是一种特殊的代币,

这是一个普通的SOL账号,其owner为”System Program”,也就是系统程序”11111111111111111111111111111111"

在来看下一个程序账号:

程序账号的owner为”BPFLoader2"也就是”BPFLoader2111111111111111111111111111111111",

另外最大的不同就是他的Executable属性是”YES”, 而在转账的代码里面有这么个判断:

// The balance of read-only and executable accounts may not change
if self.lamports != post.lamports {
if !self.is_writable {
return Err(InstructionError::ReadonlyLamportChange);
}
if self.is_executable {
return Err(InstructionError::ExecutableLamportChange);
}
}

如果是程序,直接返回出错。

那我们能不能将其放在文件账号中呢?答案也是否定的:

// Only the owner of the account may change owner and
// only if the account is writable and
// only if the account is not executable and
// only if the data is zero-initialized or empty
if self.owner != post.owner
&& (!self.is_writable // line coverage used to get branch coverage
|| self.is_executable
|| *program_id != self.owner
|| !Self::is_zeroed(&post.data))
{
return Err(InstructionError::ModifiedProgramId);
}

这里要求owner必须是系统程序。

因此我们只能在弄一个管理员账号,用来存收到的SOL,同时也用它来发奖励。

计算资源和日志资源限制

有时候我们在调试程序的时候,会遇到:

Program GJqD99MTrSmQLN753x5ynkHdVGPrRGp35WqNnkXL3j1C consumed 200000 of 200000 compute units
Program GJqD99MTrSmQLN753x5ynkHdVGPrRGp35WqNnkXL3j1C BPF VM error: exceeded maximum number of instructions allowed (193200)
Program GJqD99MTrSmQLN753x5ynkHdVGPrRGp35WqNnkXL3j1C failed: custom program error: 0xb9f0002

这样的错误,这是为啥呢?实际上是因为计算资源被耗干了。runtime对合约的运行做了一些限制, 当前的限制可以在 SDK中查询:

BpfComputeBudget {
max_units: 100_000,
log_units: 0,
log_64_units: 0,
create_program_address_units: 0,
invoke_units: 0,
max_invoke_depth: 1,
sha256_base_cost: 85,
sha256_byte_cost: 1,
max_call_depth: 20,
stack_frame_size: 4_096,
log_pubkey_units: 0,
};
if feature_set.is_active(&bpf_compute_budget_balancing::id()) {
bpf_compute_budget = BpfComputeBudget {
max_units: 200_000,
log_units: 100,
log_64_units: 100,
create_program_address_units: 1500,
invoke_units: 1000,
..bpf_compute_budget
};
}

当前主网和测试节点上的配置是,最大计算单元为200K,最大的日志单元100个,栈空间为4KB,堆空间 32KB。

runtime每执行一条指令,都是有消耗的,而我们的日志里面如果用了 format! 宏,那么是非常消耗 指令计算资源的。上面的例子就是这样。

同时我们也不能过多的打日志,超过100条,也会报日志资源的错误。

stack和heap限制

除了上面的错误,有时你还有可能遇到:

Transaction simulation failed: Error processing Instruction 0: Program failed to complete 
Program N42Qjxtrb1KMwCshrpbSJxj3khrZwN51VVv5Zdv2AFL invoke [1]
Program log: [solong-lottery]:Instruction: SignIn
Program log: [solong-lottery]:process_signin lottery:award[0] fund[1000000000] price[1000000000] billboard[Epj4jWrXq4JsEAhvDKMAdR47GqZve8dyKp29KdGfR4X] pool[255]
Program log: Error: memory allocation failed, out of memory
Program N42Qjxtrb1KMwCshrpbSJxj3khrZwN51VVv5Zdv2AFL consumed 109954 of 200000 compute units
Program N42Qjxtrb1KMwCshrpbSJxj3khrZwN51VVv5Zdv2AFL BPF VM error: BPF program Panicked in at 0:0
Program failed to complete: UserError(SyscallError(Panic("", 0, 0)))
Program N42Qjxtrb1KMwCshrpbSJxj3khrZwN51VVv5Zdv2AFL failed: Program failed to complete

这样的错误。这个错误根据日志提示,很直观,是说内存不够用了。

在上面的配置中,我们看到了栈大小被限制在4KB,而上面没有列出来的堆大小,其实也有个限制,是32KB。 所以我们再使用栈空间的时候,需要注意,这个大小限制。另外存储的Vec以及Box对象的时候,也要注意不 能超过32KB。

另外函数调用嵌套也不能超过20层,也就是要注意递归调用以及嵌套调用深度。

ELF error

除了运行时报错,有时候我们在发布程序时可能遇到:

Error: dynamic program error: ELF error: ELF error: .bss section not supported

或者:

Failed to parse ELF file: read-write: base offet 207896

等报在ELF上的错误,一般是我们用了Solana目前不支持的rust的特性,比如用了HashMap。或者不支持 SDK的接口如:find_program_address。

Solana目前支持的Rust的标准库有:

  • No access to
  • rand or any crates that depend on it
  • std::fs
  • std::net
  • std::os
  • std::future
  • std::process
  • std::sync
  • std::task
  • std::thread
  • std::time
  • Limited access to:
  • std::hash
  • std::os
  • Bincode is extremely computationally expensive in both cycles and call depth and should be avoided
  • String formatting should be avoided since it is also computationally expensive
  • No support for println!, print!, the Solana SDK helpers in src/log.rs should be used instead
  • The runtime enforces a limit on the number of instructions a program can execute during the processing of one instruction

参见官方文档

程序更新

另外,当我们把程序写完了,也调好了。但是这个时候PM改需求了,但是我们可以更新已经发布的程序么?

不好意思,答案是否定的,已经发布的程序不能再修改了。通过跟官方团队的沟通,Solana后续是规划了 程序更新的功能 的,我们还是静候佳音吧。

但是在目前的情况下,我们可以考虑几个方式。

方式一:对于有轮转概念的,在前端兼容

如果我们的逻辑,是一局一局式的,比如一轮一轮开奖,我们有个Account来记录开奖结果,而另一个记录 玩家的Account,每局都会清空。那么这种情况,我们之间发布新程序就可以了。在前端查询中奖结果的时候 去新老记录结果的Account都去查询就可以了,再下一轮的时候,就可以使用新程序了。

方式二:自建copy方法

如果我们有n个Account记录了数据,那么在新程序发布后,我们可以为其增加一个copy的方法,然后将 owner为原来程序的Account都重新创建一个Account,并通过合约的copy方法,逐一进行copy。

这样新程序起来后,就也拥有了老程序一样的数据。

总结

总的来说,Solana合约SDK基本上还算友好,代码的注释和基本功能都比较完善,官方的例子和程序可以 作为一个比较好的示范,当需要某个功能,但是又不知道怎么去实现的时候,可以从官方的几个程序中去找下 解决方案。这些可能存在的坑,只要去趟过一次后,后面再去实现一个完整的DApp的时候,基本就比较 顺畅了。

参考

  1. Solana SDK
  2. SolanaDevelopment

--

--