Solana学习

  • maodaishan
  • 更新于 2024-03-10 16:07
  • 阅读 871

对Solana的一些学习 本文内容主要来自Solana官方文档和一些查阅的网上资料。时间:2023.12

本文内容主要来自Solana官方文档和一些查阅的网上资料。时间:2023.12

1. 账户

Solana上的一切都是账户。账户的基本结构如下:

pub struct Account {
 /// lamports in the account
 pub lamports: u64,
 /// data held in this account
 #[serde(with = "serde_bytes")]
 pub data: Vec<u8>,
 /// the program that owns this account. If executable, the program that loads this account.
 pub owner: 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,
 }
  • lamports:余额
  • data:账户有数据账户和程序账户之分。程序账户只能存代码,不能存数据;数据账户可以存数据。\ 程序账户的data就是编译后的程序;
  • owner:。所有者。只有owner才能写入数据。数据账户的owner是程序;程序账户的owner是一个系统程序账户:system program
  • executable:是否是程序账户
  • rent_epoch:账户得有余额,验证者才会保存这个账户。没余额的话账户就会被删掉。

账户通过SystemProgram::CreateAccount来创建,注册公钥。账户数据最大10MB,可以按10KB来增大账户大小。账户创建过程中可以加盐(derive新账户地址)(SystemProgram::CreateAccountWithSeed, Pubkey::CreateProgramAddress)

没注册过的账户也可以传给程序,此时程序会用一个SystemProgram拥有的没数据,0端口的账户来代替这个账户。

账户有owner属性,只有owner才能操作账户的数据。新创建的账户的owner是systemProgram,它可以设置账户的owner。如果某个账户的owner不是某个程序,则这个程序只能读这个账户的数据,不能写。

出于安全考虑,每个程序在运行时,都应该检查账户的有效性(比如这是不是它拥有的账户)。因为恶意的账户可能会写入误导程序的任意数据,而任何人都可以调用程序,输入不是它拥有的账户(读数据)

保存账户需要交租金。目前新账户都要求做到免租,即余额大于2年租金。任何导致其余额小于两年租金的操作都会失败。getMinimumBalanceForRentExemption可以查询到最低余额。

合约也是账户,分为系统合约和用户合约。都能被用户调用。

系统合约(官方在节点直接部署的):

  • System Program: 创建账号,转账等作用
  • BPF Loader Program: 部署和更新合约
  • Vote program: 创建并管理用户POS代理投票的状态和奖励

用户合约(用户部署的):通过BPF部署,合约account中的executable设置为true。用户合约可以更新和销毁(也可以设置为不可更新)。销毁时,存储合约代码消耗的资源会被返还给部署者。

Owner

account中的表示这个account可以被哪个合约读写。一般来说,用户合约的owner就是BPF Loader。

存放我们token的余额的account的owner就是Token的合约。

租金

每个账户都得有余额来支付租金,其数据才能被保存在链上。没有租金的话,就会被删除,清除所有数据。

如果余额达到一定水平,就可以免租金。

租金率是可以设置的。按每年每字节的lamports来计算。

账户里lamports超过2年租金的账户,可以免租金。每次账户余额减少时,都会检查该账户是否仍免租金。 导致账户余额低于租金豁免阈值的交易将会失败。

2. 交易

交易签名数组消息构成。

签名数组中的每项都是给定消息的ED25519签名。runtime会验证签名的数量跟消息头的前8bit里记录的数量一致,并且验证每个签名是否由消息账户地址数组中对应索引的私钥签名的。

对指令来说,指令的program id指定哪个程序处理指令;这个程序的owner指定哪个加载器(也就是程序的owner)来执行程序。例如对于用户程序,其owner都是SBF Loader,所以就是SBF程序来执行用户程序。

消息的结构和解释如下面结构体所示

注意这里的recent_blockhash,它有两个作用:

  1. 当作nonce,如果两个交易完全一样,则被忽略;但如果交易内容一样但recent_blockhash不一样,会被当作不同交易; 而且用这个值也可以表明交易的顺序。
  2. 太久之前的交易会被丢弃

recent_blockhash的有效期是150个区块(注意不是slot)。有些slot可能在出块时会被跳过,所以实际区块年龄可能略比150个slot长一点。slot时间约为0.5s,所以一个recent_blockhash的有效期约为1分19秒。

pub struct Message {
 ///3个uint8,第一个表示交易中签名的数量;第二个表示只读账户数量;第三个表示不需要签名的只读账户的数量。
 pub header: MessageHeader,

 /// 地址列表。
先是需要签名的,读写的在前,只读的在后。
然后是不需要签名的,同样是读写的在前,只读的在后。
 #[serde(with = "short_vec")]
 pub account_keys: Vec<Pubkey>,

 /// 最近的区块hash,表示客户端上次观察账本的时间。如果区块hash太旧,交易会失败;另外这个也可以当作nonce来用
 pub recent_blockhash: Hash,

 /// 交易列表。按顺序调用一系列合约,在一个原子操作中commit。
 ///详情见下面的结构体。
 #[serde(with = "short_vec")]
 pub instructions: Vec<CompiledInstruction>,

 /// address查找表(ALT)。上面的account_keys会限制每笔交易可以访问32个地址,在ALT帮助下,可以把限制提高到256个地址。
这个位也可以用来压缩交易。所有要访问的地址都被放在这里后,在instructions里,就可以用索引,把要引用的32byte的地址,改为1byte的索引了。
//要使用ALT,必须交易类型为0,而不是legacy。
 #[serde(with = "short_vec")]
 pub address_table_lookups: Vec<MessageAddressTableLookup>,
 }
pub struct CompiledInstruction {
 /// 注意是个索引,用来找到执行的程序。它是指向message里ALT的索引,
 pub program_id_index: u8,
 /// 哪些accounts要传入这个调用,同样是只想message里ALT的索引
 #[serde(with = "short_vec")]
 pub accounts: Vec<u8>,
 /// 执行的入参
 #[serde(with = "short_vec")]
 pub data: Vec<u8>,
 }

Solana上的交易签名使用ed25519。用户的地址就是私钥对应的公钥,用Base58编码表示。

Solana支持两种交易版本:

  • legacy,比较老的版本
  • 0,增加了address lookup tables (见上面结构定义)

RPC交易请求都应指定他们在程序里支持的最高版本。如果返回的交易版本高于设置的版本,则RPC请求失败。

交易费

leader验证客户端提交的交易后,就会收交易费。

执行交易时可用的资源量和大小都有上限。目前交易费仅取决于要验证的签名的数量。而签名数量的限制是交易本身的大小。交易(最大1232bytes)中每个签名(64bytes)必须用唯一的公钥(32bytes),所以单个交易可以包含最多12个签名。可以使用cli获取每个交易签名的费用。

$ solana fees
Blockhash: 8eULQbYYp67o5tGF2gxACnBCKAE39TetbYYMGTx3iBFc
Lamports per signature: 5000
Last valid block height: 94236543

需要注意,费率(Lamports per signature)会随着区块而变化(虽然尚未发生过)。但即使如此,因为一笔交易的有效期最大约为1分19秒,我们还是能很准确的评估交易费用。

可写签名者账户中的第一个会实际支付费用。

处理交易前会先扣费,如果余额不足,交易失败;如果费用重组,无论交易是否成功,都会扣费。

交易费部分会被销毁,剩下的发给验证者。销毁率为50%。

交易生命周期

交易声明周期如下:

  1. 创建指令列表以及指令需要读取和写入的帐户列表
  2. 获取最近的区块哈希并使用它来准备交易消息
  3. 模拟交易以确保其行为符合预期
  4. 提示用户使用私钥对准备好的交易消息进行签名
  5. 将交易发送到 RPC 节点,该节点尝试将其转发给当前的区块生产者
  6. 希望区块生产者验证交易并将其提交到他们生产的区块中
  7. 确认交易已包含在区块中或检测交易何时过期

区块hash:是slot的最后一个PoH hash。因为solana使用PoH作为可信时钟,所以交易的最近区块hash可以被看作是时间戳。

PoH的hash计算:next_hash = hash(prev_hash, hash(transaction_ids))

每个块都包含块hash和一个称为'ticks'的hash检查点列表,用于让validators可以并行验证完整的hash链,并证明过去了一段时间。

验证交易时,validator会查找交易的区块hash对应的slot号码,如果找不到,或找到的号码比当前最新块的slot号码低151以上,交易就过期。slot配置为持续400ms,但通常会在400-600ms间波动。所以交易的区块hash的过期时间一般在60-90秒。

为什么设置一个这么小的交易过期时间?是为了避免验证者处理同一笔交易两次。否则就得考虑在更长的时间内检查同一笔交易之前是否有处理过了。

与以太坊的nonce方案相比,solana :

  • 一个账户可以同时提交多笔交易,且允许乱序处理。(如同时用多个程序)
  • 如果过期了,用户可以再次尝试,因为过期很容易。这样避免了长期的交易待处理状态。

缺点:

  • validator必须跟踪最近的已处理交易id,防止重复处理
  • 用户提交交易的时间窗比较小,交易比较容易过期。

https://docs.solana.com/developing/transaction_confirmation 这里有一些关于如何更好地选择最近区块hash的提议,以及一些可能碰到的问题。

另外针对一些特殊场景(如离线签名),交易过期很难避免,此时可以使用durable transaction(持久签名)。介绍也在上面链接里。

3. 程序

即智能合约。程序就是executable是true的账户。

程序可以是其他账号的owner;

只能修改它自己own的账户的data或debit;

任意程序都可以读其他账户,或credit其他账户(应该是转账给的意思)

程序是无状态的,因为它的数据仅仅是部署的SBF合约代码

owner可以升级程序

程序可分为内置程序和用户部署的链上程序。

内置程序包括:

  • System Program:创建新帐户、分配owner,支付交易费用
  • Config:添加配置,以及权限公钥列表;它只有一个store指令,把一组keys和配置文件保存上去。
  • Stake:validator用的抵押程序
  • Vote:管理validator投票和收益的
  • Address Lookup Table(ALT):发交易时压缩相关地址查询的
  • BPF Loader Program: 在链上部署、升级、执行程序。是所有用户程序的owner。
  • Ed25519:验证签名
  • Secp256k1:恢复公钥的,类似ecrecover

跨程序调用:https://docs.solana.com/developing/programming-model/calling-between-programs

sysvar

solana通过sysvar账户(有多个),向外界提供对系统状态的查询。有两种方法可以查询它们:

  1. get()。如: let clock = Clock::get()。下面这些支持get函数。
  • EpochSchedule,epoch调度的常量,如epoch中的slot数,某个slot属于的epoch等
  • Clock,时间相关,包括slot, epoch,timestamp等。每个slot都会更新。
  • Fees,当前slot的费用计算器
  • Rent,租金,写在创世块里,不变。Rent的燃烧比例是手工可调整的。
  • EpochRewards,查询epoch奖励分配。注意有奖励的时候才能查到,分完了就查不到了。
  1. 把sysvar账户当作普通账户,把它们的地址传入程序,可以反序列化它们的数据来获取信息,注意sysvars是只读的。
   let clock_sysvar_info = next_account_info(account_info_iter)?;
   let clock = Clock::from_account_info(\&clock_sysvar_info)?;

以下是其他一些sysvar(不支持get()):

  • Instructions: 正在处理的Msg的序列化数据。可以用来查询当前tx里的其他instructions。
  • RecentBlockhashes: active recent blockhashes 和相关的fee 计算器,每个slot更新。
  • SlotHashes:最近的slot的hash
  • SlotHistory:上一个epoch的slots的bitvector
  • StakeHistory:追踪epoch里奖励的分发情况。
  • LastRestartSlot:上一个restart的slot,或者0

zk-token-proof:https://docs.solana.com/developing/runtime-facilities/zk-token-proof

4. Runtime (运行时)

Runtime只允许程序修改它own的账户的数据,或转账。程序决定客户端是否可以修改它own的账户的数据。例如对系统程序来说,它通过验证签名来决定用户是否可以转账。

换句话说,程序拥有的所有accounts集合,可以看作一个kv存储。key是账户地址,v是任意数据。程序决定如何管理所有的状态。

一些运行时规则:

  • 只有帐户的所有者可以更改所有者。

    • 并且仅当帐户可写时。
    • 并且仅当该帐户不可执行时。
    • 并且仅当数据为零初始化或为空时。
  • 未分配给该程序的账户不能减少其余额。

  • 只读帐户和可执行帐户的余额不会改变。

  • 只有所有者可以更改帐户大小和数据。

    • 并且该帐户需可写。
    • 如果该帐户不可执行。
  • executable只能从false设置为true,且只有帐户所有者可以设置它。

  • 任何人都无法修改与此帐户关联的rent_epoch。

执行交易时,每个tx都会分配计算的预算,类似gas limit。tx超过limit时,或加载账户的数据大小超出限制时,就会出错。

以下操作会产生计算成本:

  • 执行SBF指令

  • 在程序之间传递数据

  • 调用系统调用\

    • 记录
    • 创建程序地址
    • 跨程序调用
    • ...

对于跨程序调用,调用的指令继承其父级的预算。

任意交易:

  • 如果不执行其他操作,可以执行 1,400,000 个 SBF 指令。
  • 堆栈使用量不能超过 4k。
  • SBF 调用深度不能超过 64。
  • 不能超过调用堆栈高度 5(4 个级别的跨程序调用)。

优先排序:支持支付优先费用,可以让自己的交易排在其他的前面。

5.SPL代币(ERC20)

类似ERC20。 SPL Token是 " Solana Program Library"中的一个组成部分,叫做"Token Program",简称为SPL Token。

所有的代币都有这个合约来管理,该合约代码在 https://github.com/solana-labs/solana-program-library/tree/master/token

以太坊里是一个代币一个合约;而Solana里不一样,所有的代币都用同一个合约,而每个不同的代币只是不同的account。它拥有如下数据:

pub struct Mint {
 /// Optional ,能mint新token的权限管理。只有在mint时才检查。如果没有这个位,则token 
 ///只有固定的supply。
 pub mint_authority: COption<Pubkey>,
 /// 供应上限
 pub supply: u64,
 /// 小数点,按10进制.
 pub decimals: u8,
 /// 是否已经被初始化
 pub is_initialized: bool,
 /// Optional ,冻结账号的权限.
 pub freeze_authority: COption<Pubkey>,
 }

每个用户的拥有的代币数量信息存在哪里呢?

这个合约又定义了一个账号结构,来表示某个地址含有某个代币的数量。

pub struct Account {
 /// The mint associated with this account
 /// 是指这个账户account属于哪个代币吗?
 pub mint: Pubkey,
 /// 代币的拥有者
 pub owner: Pubkey,
 /// 代币数量.
 pub amount: u64,
 /// 跟ERC20的delegate一样
 pub delegate: COption<Pubkey>,
 /// 账户状态
 pub state: AccountState,
 /// 表示是否是native token。其值代表免租,这个账户要求免租,这个value表示确保wrapped SOL account的余额不要低于这个值。
 pub is_native: COption<u64>,
 /// The amount delegated
 pub delegated_amount: u64,
 /// Optional authority to close the account.
 pub close_authority: COption<Pubkey>,
 }

如下图:

Wallet Account就是钱包,Mint Account就是一个特定的代币,PDA应该就是拥有代币的用户account。

Wallet Account对应Owner,可以发起转账。

Mint Account里的mint_authority可以给某个PDA账户mint 代币。

6. 架构

6.1 PoH 历史证明

https://juejin.cn/post/7049556933589598222

PoH是Solana高性能的重点。它不是共识机制,但可以用来提供更高性能的时间戳,提高PoS的共识性能和数据层的性能。

solana使用PoH的方法是VDF(可验证延迟函数),但它的验证不是真正算法上的VDF,仅仅是把PoH里的hash序列分割成很多(如4000份),然后用4000个GPU核心来分别验算而已。

6.2 概述

solana的浏览器:https://explorer.solana.com/

各个不同集群的入口:https://docs.solana.com/clusters

https://docs.solana.com/cluster/rpc-endpoints

solana是开放的,允许任何人启动自己的solana节点和网络。现在运行着多个solana集群网络,如主网,测试网,开发网,以及其他网络。

主网中有4个known-validator是由solana Labs运营的。

对一个solana集群来说,每个时隙(400-600ms)会分配一个leader,每个leader会连续当4个slot的leader,leader在它的时隙内可以生产entity,在当前状态上执行交易,为交易签名(通过PoH);然后发给其他验证者节点。会拆分发送,比如有60笔交易,发给6个抵押最多的验证者,就拆成6份,每份10笔,然后由验证者再用类似的方法分发交易,这样可以减小leader的网络负担,提升最大吞吐量。这可以让一个entity在极短时间内传遍全网。这称为Turbine协议。为什么Solana能这么做,而其他链不能呢?因为其他链有区块概念,区块基本上是不可切分的,所以需要一次性传一个完整区块,这让出块者的带宽传输了重复的内容,降低了带宽利用率。而solana上的PoH可以切分传播,后续只要收集到完整的多个分片,就可以再打包起来,这样也不会破坏多个分片之间的顺序。

另外Turbine协议下,节点按照其质押资产的权重,被划分为不同的层级(优先级),质押资产多的Validator率先收到Leader发出的数据,之后由这些节点传递给下一层。在这种机制下,占全网质押资产2/3权重的节点群体,会最先收录Leader发出的交易序列,加快账本(区块)的确认速度。

技术上来说,solana上没有区块,而是用entity。谈到区块时间只是为了方便跟其他链做比较。当前区块时间设置为800ms.

leader把一批有效交易添加到entity中,然后发送到validator,validator处理交易,然后开始投票。如果没达成共识,节点就把状态回滚。

有个leader schedule,表明所有slot的leader。这个表会在本地定期重新计算。在epoch内分配slot的leader。一个epoch包含43.2万个slot,大概2天。schedule出来的时间比slot到达的时间早,这样才可以有确定的schedule。这个早出来的时间称为schedule offset。在solana里,一个epoch的leader schedule是使用上一个epoch的第一个块的账本状态计算出来的。这个时间足够长,所以每个validator都有时间计算出leader schedule。

每个slot里,包含T个PoH tick,所以每个validator对slot的定义也能对齐了。

在一个slot里,leader生产并传输entries,在T个tick后,所有的validator切换到下一个slot的leader。

所有的T ticks必须能被下一个leader看到,因为它要基于这个结果来工作。如果当前leader宕机,或者它创建的entries是invalid,下一个leader必须生产ticks来补充上一个leader的slot。注意下一个leader必须并行准备这个修复工作,并且推迟发送这些补充的tick,直到它确信其他的validator也没收到上一个leader的entries。如果一个leader错误地构建了自己的ticks,后面的leader必须把这个错误leader的ticks全部替换。

每发送N个entity,Leader就会公开自己的本地状态state,validator会将其与自己的state对比,投票。使用BFT完成同步。如果规定时间内,Leader能收集到全网2/3抵押权重的投票,则发布的交易序列和状态State就确认。

Leader会收集validator的投票,并发送出来,这跟其他的BFT不同,避免了大量的节点间投票的传输,降低了网络消耗。不过Leader会把共识投票也当作交易来发布,它发布的交易序列包含节点投票,所以实际的entity里,投票占了TPS的主要部分(一般70%以上)。如果Solana的共识节点数目增大,会导致更多节点参与投票,从而进一步提高投票交易占总交易的比重。所以实际的solana的TPS没有那么高,而且在安全性和性能之间有冲突。

Solana的节点是可预测的(提前2天就知道了),所以客户端收到用户的交易后,会直接发送给leader,而不是广播给所有节点。这大大提高了效率,但是降低了安全性,所以leader比较容易受到攻击,当网络很忙时,也容易宕机(相当于遭受DDOS攻击)。最新消息,它引入了谷歌的quic数据传输协议,在流量过高时会自动丢包,限流,所以现在宕机事件少了。

分叉

Solana不会等一个块达成共识后才生产下一个块,所以会发生分叉。验证者们要对分叉进行投票,通过共识确定使用哪个分叉。

集群可以容忍一个leader掉线,因为它们可以算出这个leader应该经过的tick后,最终的达到的PoH应该是什么样子。但这个期间不会有状态变化(无交易处理)。

消息流:

  1. 交易由当前Leader提取。

  2. Leader 过滤有效交易。

  3. Leader执行有效交易更新其状态。

  4. Leader 根据其当前 PoH slot 将交易打包到entries中。

  5. Leader将entries传输到验证者节点\

    1. PoH 流包含ticks;空条目表示Leader的活跃度以及集群上的时间流逝。
    2. Leader的stream从完成 PoH 所需的,观察到的之前leader的slot的最新的tick entries开始。
  6. Validators将entries重新传输到其集群中的节点以及更下游的节点。

  7. Validators验证交易并在其状态上执行它们。

  8. Validators计算新状态的哈希值。

  9. 在特定时间,即特定 PoH tick,Validators将投票发送给leader。\

    1. 投票是在该 PoH 计数时计算出的状态的哈希签名。
    2. 投票也通过gossip传播。

\

  1. Leader执行投票,与任何其他tx相同,并将其广播到集群。
  2. Validators观察他们的投票以及集群中的所有投票。

\

如图,L是leader, E代表Entry,x代表L没出块的选择。这里L4观察到L3出了两个Entry,所以L3要被slash。每个Leader只需要做一个选择:Entry还是x。所以有分叉也会很快结束。

当新的leader开始一个slot时,它必须先传输把新slot和最近观察和投票的slot链接所需的PoH。这样就能衔接起来。

具体分叉选择通过Tower-BFT完成。https://docs.solana.com/implemented-proposals/tower-bft

分叉选择和分叉数据的修剪:https://docs.solana.com/cluster/managing-forks

交易确认

交易分为已处理,已确认,最终确定三种状态,定义如下:

质押,投票和委托投票:

https://docs.solana.com/cluster/stake-delegation-and-rewards

共识流水线

共识使用流水线模式。有两个流水线进程:

领导者模式:TPU

验证器模式:TVU

它们需要的硬件条件是一样的。TPU是为了创建entity, TVU是验证它。

TPU:

  1. Fetch: 获取交易
  2. SigVerify:对数据包处理,删除重复的,丢弃无效的,验证签名,
  3. Banking:决定转发,保留,还是处理数据。如果当前是leader,就用PoH处理收到的包。
  4. broadcast:从PoH服务收到打成Entry的包后,签名并发送到其他节点。

TVU

从上游获取数据包后,验证Leader签名,转发数据包,并重放验证PoH,和交易,看是否修改状态。

转发:

BlockStore

当一个区块达到最终确定性后,从这个区块到创世区块的链就确定了。但是在那之前,验证者需要维护所有可能的分叉。

BlockStore以key-value形式保存区块链,key是entry的slot index和shred index;value是entry的数据。

Runtime

runtime是个并发Tx处理器。每个Tx可以预先指定其数据依赖,要用的动态内存也是可预计的。

访问只读账户的tx可以并行,写账户的tx得串行执行。

总结:

Solana通过PoH管理时间戳。

它把时间分为epoch,每个epoch包含大概43.2万个slot,每个slot是400-600ms,所以一个epoch大概2天。

在每个epoch开始时,就会定下下一个epoch的所有的slot的leader,这会带来一些攻击可能。

每个slot里,leader会生成entry,entry是跟PoH结合的交易列表,并将其拆散后快速分发出去,然后大家验证和投票。

因为slot时间很短,所以下一个leader未必能接收到上一个leader发出的entry,所以就可能分叉。分叉通过投票解决。当entry被2/3以上节点投票后,就确认了。后续有31个确认块后,就最终确认了。

由于投票也是作为交易处理,所以当前处理的交易中,70%以上,甚至90%,都是投票交易。真正用户的交易比例比较低。而且如果solana的去中心化比较好,那么前2/3抵押的节点总数肯定就更高,要通知的节点就多,投票占总交易的比例就更高。所以solana的去中心化程度(安全性)和交易有效率成反比,这是不太好的。

点赞 1
收藏 2
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
maodaishan
maodaishan
0xee37...1912
江湖只有他的大名,没有他的介绍。