区块链中文技术社区

Solana - 全量同步节点数据

背景

当Solana 新增外部数据节点,开始同步后,前期的区块数据缺失

测试配置

./agave-validator --ledger "$TESTNET_DIR"/rpc-explorer --identity "$TESTNET_DIR"/rpc-explorer/identity.json --only-known-rpc --full-rpc-api --no-voting --log "$TESTNET_DIR"/rpc-explorer/rpc-val.log --log-messages-bytes-limit 104857600 --known-validator Hk42DsKjJxHufjRnzXMWD32... --allow-private-addr --enable-rpc-bigtable-ledger-storage --enable-rpc-transaction-history --rpc-port 8888 --entrypoint 172.18.39.93:8001

测试节点信息

服务类型 机器ip 目录 RPC端口
Validator 172.18.39.93 /data/agave/config/bootstrap-validator 8899
ExplorerRpc 172.18.34.76 /data/testnet-rpc/rpc-explorer 8888
PublicRpc1 172.18.34.76 /data/testnet-rpc/rpc-1 8890
PublicRpc2 172.18.34.76 /data/testnet-rpc/rpc-2 8892
PublicRpc3 172.18.34.76 /data/testnet-rpc/rpc-3 8894

测试查询

curl http://172.18.34.76:8894/ \
  -X POST \
  -H "Content-Type: application/json" \
  --data '{"jsonrpc": "2.0","id":1,"method":"getBlock","params":[1000, {"encoding": "jsonParsed"}]}'

返回

{"jsonrpc":"2.0","error":{"code":-32001,"message":"Block 1000 cleaned up, does not exist on node. First available block: 214204"},"id":1}

查询214203,与上面结果相同

查询214204

curl http://172.18.34.76:8894/ \
  -X POST \
  -H "Content-Type: application/json" \
  --data '{"jsonrpc": "2.0","id":1,"method":"getBlock","params":[214204, {"encoding": "jsonParsed"}]}'

正常返回数据

{"jsonrpc":"2.0","result":{"blockHeight":214202,"blockTime":1733728218,"blockhash":"99HZhuvyp2MN61eo2PzCmEuhNavaThR6ZU1aX6qeNqXf","parentSlot":214203,"previousBlockhash":"GcwivEHrdh7EDc72LayQqaEkYHDgPuRaEmbVEXSS7JdX",...}

分析

当前配置下,新起的节点只保存最新的区块,并没有从起始位置同步,与以太坊节点逻辑不同

跟进代码

先看下错误信息触发的逻辑
rpc/src/rpc.rs

pub async fn get_block(
        &self,
        slot: Slot,
        config: Option<RpcEncodingConfigWrapper<RpcBlockConfig>>,
    ) -> Result<Option<UiConfirmedBlock>> {
        if self.config.enable_rpc_transaction_history {
fn check_slot_cleaned_up<T>(
        &self,
        result: &std::result::Result<T, BlockstoreError>,
        slot: Slot,
    ) -> Result<()> {
        let first_available_block = self
            .blockstore
            .get_first_available_block()
            .unwrap_or_default();
        let err: Error = RpcCustomError::BlockCleanedUp {
            slot,
            first_available_block,
        }
        .into();
        if let Err(BlockstoreError::SlotCleanedUp) = result {
            return Err(err);
        }
        if slot < first_available_block {
            return Err(err);
        }
        Ok(())
    }

当开启enable_rpc_transaction_history时,查询get_block/get_block_time,当前查询的slot 小于get_first_available_block时,则代表已被清理

/// Blockstore 账本中第一个完整的区块
    pub fn get_first_available_block(&self) -> Result<Slot> {
        let mut root_iterator = self.rooted_slot_iterator(self.lowest_slot_with_genesis())?;
        let first_root = root_iterator.next().unwrap_or_default();
        // 如果第一个根是 slot 0,则它是创世。创世总是完整的,因此将其作为第一个可用返回是正确的。
        if first_root == 0 {
            return Ok(first_root);
        }
        // 否则,root-index 0 处的块永远无法完成,因为它缺少其父块哈希。必须根据前一个
        // 块的条目计算父块哈希。因此,第一个可用的完整块是root-index 1 处的块。
        Ok(root_iterator.next().unwrap_or_default())
    }

从代码分析,当前新起的节点应该是从cluster获取了快照,快速同步了state数据,为了精简本地存储,区块数据并没有同步

节点配置

参数 解释 备注
identity 验证者身份密钥对
authorized_voter_keypairs 包含额外的授权投票者密钥对。可以多次指定。[默认:--identity 密钥对]
vote_account 验证者投票账户公钥。如果未指定,投票将被禁用。账户的授权投票者必须是 --identity 密钥对 或由 --authorized-voter 参数设置
init_complete_file 如果验证器初始化完成后该文件尚不存在,则创建该文件
ledger_path 使用 DIR 作为账本位置
entrypoint 在此gossip入口点与集群会合
no_snapshot_fetch 不要尝试从集群中获取快照,如果存在,则从本地快照开始
no_genesis_fetch 不要从集群中获取创世数据
no_voting 启动验证者但不进行投票
check_vote_account 启动时检查投票账户状态是否正常。RPC_URL 处的 JSON RPC 端点必须公开 --full-rpc-api
restricted_repair_only_mode 不要发布 Gossip、TPU、TVU 或 Repair Service 端口。这样做会导致节点以有限的容量运行,从而减少其对集群其余部分的暴露。当启用此标志时,--no-voting 标志是隐式的
dev_halt_at_slot 当验证器到达指定时隙时,停止验证器 可用于特殊场景测试
rpc_port 在此端口上启用 JSON RPC,并在下一个端口上启用 RPC websocket 如果当前RPC为8545,默认WS为8546
full_rpc_api 公开用于查询链状态和交易历史的 RPC 方法 对于Public RPC必须的
private_rpc 不要发布 RPC 端口供他人使用 对于core RPC应开启
no_port_check 启动时不执行 TCP/UDP 可达端口检查
enable_rpc_transaction_history 通过 JSON RPC 启用历史交易信息,包括 getConfirmedBlock API。这将导致磁盘使用率和 IOPS 增加 导致磁盘占用的主要原因
enable_rpc_bigtable_ledger_storage 从 BigTable 实例获取历史交易信息作为本地账本数据的后备 BigTable
enable_bigtable_ledger_upload 将新确认的块上传到 BigTable 实例 BigTable
enable_extended_tx_metadata_storage 存储的历史交易信息中包含 CPI 内部指令、日志和返回数据 类似以太坊debug?
rpc_max_multiple_accounts 覆盖 getMultipleAccounts JSON RPC 方法接受的默认最大账户数 默认100
health_check_slot_distance 如果验证器的最新重放乐观确认槽位于集群最新乐观确认槽的指定槽数之内,则报告此验证器为健康
rpc_faucet_addr 使用此水龙头地址启用 JSON RPC“requestAirdrop”API。 关闭
account_paths 逗号分隔的持久帐户位置。可以多次指定。[默认值:LEDGER/accounts]
account_shrink_path 指向账户收缩路径的路径,可以保存压缩的账户集
accounts_hash_cache_path 使用 PATH 作为账户哈希缓存位置 [默认值:LEDGER/accounts_hash_cache]
snapshots 使用 DIR 作为快照的基本位置。将创建一个名为“snapshots”的子目录。[默认值:--ledger 值]
use_snapshot_archives_at_startup 启动时,应何时提取快照存档,而不是使用磁盘上已有的存档。指定“always”将始终通过提取快照存档来启动,并忽略磁盘上已有的任何快照相关状态。请注意,从快照存档启动将产生与提取存档和重建本地状态相关的运行时成本。指定“never”将永远不会从快照存档启动,并且只会使用磁盘上已有的快照相关状态。如果磁盘上没有状态,启动将失败。请注意,这将使用可用的最新状态,该状态可能比最新的快照存档更新。指定“when-newest”将使用磁盘上已有的快照相关状态,除非有比它更新的快照存档。如果在节点停止时下载了新的快照存档,则可能会发生这种情况。
full_snapshot_archive_path 使用 DIR 作为完整快照存档位置 [默认值:--snapshots 值]
incremental_snapshot_archive_path 使用 DIR 作为增量快照存档位置 [默认值:--snapshots 值]
tower 使用 DIR 作为文件塔存储位置 [默认值:--ledger 值]
tower_storage 塔的存放位置
etcd_endpoint 要连接的 etcd gRPC 端点
etcd_domain_name 用于验证 etcd 服务器的 TLS 证书的域名
etcd_cacert_file 使用此 CA 包验证 etcd 端点的 TLS 证书
etcd_key_file 建立与 etcd 端点的连接时使用的 TLS 密钥文件
etcd_cert_file 建立与 etcd 端点的连接时使用的 TLS 证书
gossip_port 验证者的 Gossip 端口号
gossip_host 验证器在 gossip 中宣传的 Gossip DNS 名称或 IP 地址 [默认值:ask --entrypoint,当未提供 --entrypoint 时为 127.0.0.1]
public_tpu_addr 指定要在 gossip 中宣传的 TPU 地址 [默认:当未提供 --entrypoint 时询问 --entrypoint 或 localhost]
public_tpu_forwards_addr 指定 TPU 转发地址以在 gossip 中宣传 [默认:当未提供 --entrypoint 时询问 --entrypoint 或 localhost]
public_rpc_addr 验证器在 gossip 中公开宣传的 RPC 地址。对于在负载均衡器或代理后面运行的验证器很有用 [默认:使用 -rpc-bind-address / --rpc-port]
dynamic_port_range 用于动态分配端口的范围
maximum_local_snapshot_age 如果本地快照距离其他验证者可下载的最高快照的槽位少于这么多,则重复使用本地快照
no_incremental_snapshots 禁用增量快照
snapshot_interval_slots 生成快照之间的时隙数。如果启用了增量快照,则此设置增量快照间隔。如果禁用了增量快照,则此设置完整快照间隔。将此设置为 0 可禁用所有快照。
full_snapshot_interval_slots 生成完整快照之间的间隔数。必须是增量快照间隔的倍数。仅在启用增量快照时使用。
maximum_full_snapshots_to_retain 清除旧快照时要保留的完整快照存档的最大数量。 排除下本地增长是否与快照有关?
maximum_incremental_snapshots_to_retain 清除旧快照时要保留的增量快照存档的最大数量。
snapshot_packager_niceness_adj 将此值添加到快照打包器线程的优先级。负值增加优先级,正值降低优先级。
minimal_snapshot_download_speed 快照下载的最小速度(以字节/秒为单位)。如果初始下载速度低于此阈值,系统将针对不同的 rpc 节点重试下载。
maximum_snapshot_download_abort 遇到快照下载缓慢时中止并重试的最大次数。
contact_debug_interval 打印gossip联系人调试之间的毫秒数。
no_poh_speed_test 跳过 PoH 速度检查。
no_os_network_limits_test 跳过对操作系统网络限制的检查。
no_os_memory_stats_reporting 禁用操作系统内存统计信息的报告。
no_os_network_stats_reporting 禁用操作系统网络统计信息的报告。
no_os_cpu_stats_reporting 禁用操作系统 CPU 统计信息的报告。
no_os_disk_stats_reporting 禁用操作系统磁盘统计信息的报告。
snapshot_version 输出快照版本
limit_ledger_size 将此数量的碎片保留在根槽中。
rocksdb_shred_compaction 控制 RocksDB 如何压缩碎片。警告:在选项之间切换时,您将丢失 Blockstore 数据。可能的值是:'level':使用 RocksDB 的默认(级别)压缩存储碎片。'fifo':在 RocksDB 的 FIFO 压缩下存储碎片。此选项在 Blockstore 的磁盘写入字节方面更有效。
rocksdb_fifo_shred_storage_size 碎片存储大小(以字节为单位)。建议值至少为分类帐存储大小的 50%。如果未指定此参数,我们将根据 --limit-ledger-size 分配适当的值。如果没有提供 --limit-ledger-size,则表示分类帐大小没有限制,因此 rocksdb_fifo_shred_storage_size 也将不受限制。
rocksdb_ledger_compression 用于压缩交易状态数据的压缩算法。启用压缩可以节省约 10% 的账本大小。 "none", "lz4", "snappy", "zlib"
rocksdb_perf_sample_interval 控制收集 RocksDB 读/写性能样本的频率。Perf 样本以 1/ROCKS_PERF_SAMPLE_INTERVAL 采样率收集。
skip_startup_ledger_verification 在验证器启动时跳过分类账验证。
cuda 使用CUDA TODO
require_tower 如果未找到已保存的塔状态则拒绝启动
expected_genesis_hash 要求创世块有这个哈希值
expected_bank_hash 当等待绝对多数x时,要求x处的银行拥有此哈希值
expected_shred_version 要求碎片版本为此值
logfile 将日志重定向到指定文件,‘-’ 表示标准错误。向验证器进程发送 SIGUSR1 信号将导致其重新打开日志文件
wait_for_supermajority 处理完账本后,下一个 slot 是 SLOT,等到 gossip 上出现绝大多数的权益后,再开始 PoH
no_wait_for_vote_to_start_leader 如果验证器启动时没有账本,它将等待直到看到投票进入根槽后才开始生成区块。这可以防止双重签名。关闭可避免双重签名区块的风险。
hard_forks 在此位置添加硬分叉
known_validators 此验证器必须将快照哈希发布到 gossip 中才能接受。可以多次指定。如果未指定,则将接受任何快照哈希
debug_key 当处理引用给定密钥的交易时进行记录。
only_known_rpc 仅使用已知验证者的 RPC 服务 public<->core<->validator
repair_validators 请求修复的验证器列表。如果指定,则不会向此集合之外的验证器请求修复 [默认:所有验证器]
repair_whitelist 优先处理修复请求的验证器列表。如果指定,列表中验证器的修复请求将优先于其他验证器的请求。[默认:所有验证器]
gossip_validators 与之进行 gossip 的验证器列表。如果指定,gossip 将不会从此集合之外的验证器推送/拉取信息。[默认值:所有验证器]
tpu_coalesce_ms TPU 接收器中等待数据包合并的毫秒数。
tpu_use_quic 使用 QUIC 发送交易。
tpu_disable_quic 不要使用 QUIC 发送交易。
tpu_enable_udp 启用 UDP 来接收/发送交易。
tpu_connection_pool_size 控制每个远程地址的 TPU 连接池大小
tpu_max_connections_per_ipaddr_per_minute 控制每分钟每个 IpAddr 的客户端连接速率。
staked_nodes_overrides 提供 yaml 文件的路径,其中包含针对特定身份的质押的自定义覆盖。覆盖此验证器认为对网络中其他对等方有效的质押金额。质押金额用于计算对等方和投票数据包发送方阶段允许的 QUIC 流数量。文件格式:`staked_map_id: {pubkey: SOL 质押金额}
bind_address 绑定验证器端口的 IP 地址
rpc_bind_address 绑定 RPC 端口的 IP 地址 [默认值:如果存在 --private-rpc,则为 127.0.0.1,否则使用 --bind-address]
rpc_threads 用于服务 RPC 请求的线程数 默认:cpu核心数
rpc_niceness_adj 将此值添加到 RPC 线程的优先级。负值增加优先级,正值降低优先级。
rpc_bigtable_timeout BigTable 支持的 RPC 请求超时前的秒数
rpc_bigtable_instance_name 要上传到的 Bigtable 实例的名称
rpc_bigtable_app_profile_id 请求中使用的 Bigtable 应用程序配置文件 ID
rpc_bigtable_max_message_size Bigtable Grpc 客户端使用的最大编码和解码消息大小
rpc_pubsub_worker_threads PubSub 工作线程
rpc_pubsub_enable_block_subscription 启用不稳定的 RPC PubSub blockSubscribe 订阅
rpc_pubsub_enable_vote_subscription 启用不稳定的 RPC PubSub voteSubscribe 订阅
rpc_pubsub_max_active_subscriptions RPC PubSub 在所有连接中接受的最大有效订阅数。
rpc_pubsub_queue_capacity_items RPC PubSub 在所有连接中存储的最大通知数量。
rpc_pubsub_queue_capacity_bytes RPC PubSub 在所有连接中存储的通知的最大总大小。
rpc_pubsub_notification_threads RPC PubSub 用于生成通知的最大线程数。0 将禁用 RPC PubSub 通知
rpc_send_transaction_retry_ms 通过 rpc 服务发送的事务重试的速率
rpc_send_transaction_batch_ms 通过 rpc 服务批量发送交易的速率。
rpc_send_transaction_leader_forward_count 通过 RPC 服务转发交易的未来领导者的数量。
rpc_send_transaction_default_max_retries 当请求未指定时,交易广播重试的最大次数,否则重试直至到期。
rpc_send_transaction_service_max_retries 事务广播重试的最大次数,无论请求的值是多少。
rpc_send_transaction_batch_size 要批量发送的交易的大小。 默认1
rpc_send_transaction_retry_pool_max_size 事务重试池的最大大小。
rpc_send_transaction_tpu_peer 向其他节点广播交易,以替代当前领导者
rpc_send_transaction_also_leader 使用 --rpc-send-transaction-tpu-peer HOST:PORT,也发送给当前领导者
rpc_scan_and_fix_roots 在启动时验证块存储根并修复任何差距
rpc_max_request_body_size rpc服务接受的最大请求体大小
geyser_plugin_config 指定 Geyser 插件的配置文件。
snapshot_archive_format 要使用的快照存档格式。 可能影响存储空间,支持"zstd", "lz4"
max_genesis_archive_unpacked_size 下载的 Genesis 档案的最大未压缩文件总大小
wal_recovery_mode 恢复分类帐数据库预写日志的模式。
poh_pinned_cpu_core 实验:指定 PoH 固定到哪个 CPU 核心
poh_hashes_per_batch 在 PoH 服务中指定每批哈希值
process_ledger_before_services 在启动网络服务之前,全面处理本地账本
account_indexes 启用帐户索引,按所选帐户字段进行索引
account_index_exclude_key 当启用账户索引时,从索引中排除此键。
account_index_include_key 启用账户索引后,仅将特定键包含在索引中。这会覆盖 --account-index-exclude-key。
accounts_db_verify_refcounts 调试选项用于在清理之前扫描所有附加向量并验证帐户索引引用计数
accounts_db_test_skip_rewrites 调试选项可跳过免租账户的重写,但仍将其添加到银行增量哈希计算中
no_skip_initial_accounts_db_clean 验证快照银行时不要跳过初始账户清理
accounts_db_squash_storages_method 使用此方法将多个帐户存储文件压缩在一起
accounts_db_access_storages_method 使用此方法访问帐户存储
accounts_db_ancient_append_vecs 早于 (slots_per_epoch - SLOT-OFFSET) 的 AppendVecs 被挤压在一起。
accounts_db_cache_limit_mb 帐户数据的写入缓存可以达到多大。如果超出此值,则会更积极地刷新缓存。
accounts_db_read_cache_limit_mb 帐户数据的读取缓存可以达到多大,以兆字节为单位。如果给定单个值,则它将是缓存的最大大小。如果给定一对值,则它们将是缓存的低水位线和高水位线。当缓存超过高水位线时,条目将被逐出,直到大小达到低水位线。
accounts_index_scan_results_limit_mb 帐户索引扫描的累积结果可以达到多大。如果超出此值,则扫描中止。
accounts_index_memory_limit_mb 帐户索引可以占用多少内存。如果超出此限制,一些帐户索引条目将存储在磁盘上。 协调内存与磁盘的占比
accounts_index_bins 将账户指数划分为的箱数
partitioned_epoch_rewards_compare_calculation 进行正常的 epoch 奖励分配,但也使用分区奖励代码路径计算奖励,并比较最终的投票和质押账户
partitioned_epoch_rewards_force_enable_single_slot 强制进行分区奖励分配,但将所有奖励分配在 epoch 的第一个 slot 中。这应该与正常奖励分配达成共识。
accounts_index_path 持久帐户索引位置。可以多次指定。[默认值:LEDGER/accounts_index]
accounts_db_test_hash_calculation 使用 AccountsHashVerifier 中的存储启用哈希计算测试。这需要计算成本。
accounts_shrink_optimize_total_space 当设置为 true 时,系统将缩减最稀疏的账户,当整体缩减率高于指定的账户缩减率时,缩减将停止,并跳过所有其他不太稀疏的账户。
accounts_shrink_ratio 指定要缩减的帐户的缩减比率。缩减比率定义为活动字节数与使用的总字节数之比。如果帐户的缩减比率小于此比率,则该帐户将成为缩减的候选对象。该值必须介于 0 和 1.0 之间(含)。
allow_private_addr 允许联系私有 IP 地址
log_messages_bytes_limit 截断前写入程序日志的最大字节数
banking_trace_dir_byte_limit 明确启用银行跟踪,默认情况下启用该跟踪并为模拟领导者块写入跟踪文件,在账本中保留默认或指定的总字节数。此标志可用于覆盖其字节限制。
disable_banking_trace 禁用银行追踪
delay_leader_block_for_pending_fork 在重放从当前分叉下降且比下一个领导者时隙更低的区块时,延迟领导者区块的创建。如果我们不在这里延迟,我们的新领导者区块将位于与我们正在重放的区块不同的分叉上,并且集群很有可能会确认该区块的分叉而不是我们的领导者区块的分叉,因为它是在我们开始创建我们的区块之前创建的。
block_verification_method 切换用于验证账本条目的交易调度方法
block_production_method 用于生成分类帐条目的切换交易调度方法
unified_scheduler_handler_threads 更改统一调度器专用于每个块的事务执行线程数,否则按 cpu_cores/4 计算
wen_restart 指定后,验证器将进入 Wen Restart 模式,该模式将暂停正常活动。在此模式下,验证器将传播其最后一次投票,以就安全重启槽达成共识并修复所选分叉上的所有块。安全槽将是最新乐观确认槽的后代,以确保我们不会回滚任何乐观确认槽。此模式下的进度将保存在提供的文件位置。如果达成共识,验证器将自动退出,然后执行 wait_for_supermajority 逻辑,以便集群恢复执行。进度文件将保留以供将来调试。集群恢复正常运行后,可以调整验证器参数以删除 --wen_restart 并将 expected_shred_version 更新为共识中商定的新 shred_version。如果 wen_restart 失败,请参考进度文件(proto3 格式)进行进一步调试。

总结

默认情况下,当启动 RPC 节点时,它将根据通过 Solana 网络接收的区块构建其本地分类账。此本地分类账在节点启动时下载的帐户快照开始。如果未--no-snapshot-fetch在solana-validator命令行中添加任何内容,验证器通常会在启动时从网络中提取快照。这会在停止 RPC 节点的点和下载帐户快照的点之间在分类账中留下空缺间隙。为避免这种情况,请始终--no-snapshot-fetch在第一次启动节点后指定。请记住,每次提取快照时都会在本地分类账中创建一个空缺间隙。

本地账本的大小由参数决定--limit-ledger-size,以碎片为单位。碎片是固定的数据单位。碎片和块之间的转换并不固定,因为块的大小可以变化。因此,很难说节点将存储多少以时间或块数衡量的历史记录。必须根据需要对其进行调整。一个好的起点可以是 2.5 亿到 3.5 亿个碎片,这应该大约涵盖一个时代,也就是大约 3 天。

RPC 节点将存储的确切数据量还取决于参数--enable-cpi-and-log-storage和--enable-rpc-transaction-history。这些对于节点保留和提供完整的块和交易数据是必需的。

节点只能提供已存储在其本地分类账中的数据。这意味着历史记录将始终从启动节点的点开始(实际上:启动节点的快照槽)。如果网络当前处于槽 N,并且在槽 M 提取快照,那么节点将开始重建其在槽 M 和槽 N 之间的历史记录。这就是在期间发生的事情catchup,节点正在处理(重放)在 M 和 N 之间发生的所有事情,直到它赶上网络并可以处理所有当前传入的数据。

节点(理论上)可以存储高速存储所能容纳的尽可能多的历史记录(例如,如果不指定--limit-ledger-size或为其赋予巨大的值)。但是,这不会缩减到创世纪。要获取所有历史记录,可以使用内置的 Google BigTable 支持。既可以设置节点以将数据上传到 Google BigTable 实例,在那里它可以永久用于历史查询。还可以配置节点以支持对 BigTable 实例的查询。在这种情况下,对于节点在其本地分类帐中没有的任何查询,它将向 Google BigTable 发出请求,如果它在 Google BigTable 中找到它,它可以从那里提取数据。

Solana 区块链每秒能够创建许多交易。由于链上的交易量很大,RPC 节点将整个区块链存储在机器上是不切实际的。相反,RPC 操作员使用标志--limit-ledger-size来指定在 RPC 节点上存储多少个块。如果 RPC 节点的用户需要历史区块链数据,那么 RPC 服务器将必须通过 Solana bigtable 实例访问较旧的块。

如果有兴趣设置自己的 bigtable 实例,请参阅 Solana GitHub 存储库中的以下文档:solana-labs/solana-bigtable

参考

https://docs.anza.xyz/operations/setup-an-rpc-node
https://github.com/Fankouzu/solana-basic-ui/blob/fd33c886c8331eab112dadd4a42646ffb8167cec/docs/SolanaDocumention/more/exchange.md?plain=1#L76
https://github.com/rpcpool/solana-rpc-ansible/blob/3e926a16c847f55e7adf864e0cd679043089b787/README.md#access-to-historical-data
https://github.com/solana-labs/solana-bigtable/blob/79234622ab0cb26b6f4a158ddb7eace9c19186a0/warehouse-basic.sh

Solana核心概念-跨程序调用 (CPI)

跨程序调用(CPI)是指一个程序调用另一个程序的指令(instruction)。这种机制允许 Solana 程序的可组合性。 这种机制允许 Solana 程序的可组合性。

你可以将指令视为程序向网络公开的 API 端点,而 CPI 则是一个 API 内部调用另一个 API。

当一个程序发起对另一个程序的跨程序调用(CPI)时:

  • 调用程序(A)从初始交易中获得的签名者(signer)权限扩展到被调用程序(B)
  • 被调用程序(B)可以进一步对其它程序进行 CPI,最多深度为 4(例如 B->C,C->D)
  • 程序可以代表从其程序 ID 派生的 PDAs 进行“签名”

Solana 程序运行时定义了一个名为 max_invoke_stack_height 的常量,其值设定为 5。 这表示程序指令调用堆栈的最大高度。 堆栈高度从交易指令的 1 开始,每次程序调用另 一个指令时增加 1。 此设置有效地将 CPI 的调用深度限制为 4。

关键点

  • CPI 使 Solana 程序指令能够直接调用另一个程序的指令。
  • 调用程序的签名者权限扩展到被调用程序。
  • 在进行 CPI 时,程序可以代表从其自身程序 ID 派生的 PDAs 进行“签名”。
  • 被调用程序可以对其它程序进行额外的 CPI,最多深度为 4。

如何编写 CPI

编写 CPI 指令遵循与构建添加到交易中的 instruction 相同的模式。在底层,每个 CPI 指令必须指定以下信息:

  • 程序地址:指定被调用的程序
  • 账户:列出指令读取或写入的每个账户,包括其它程序
  • 指令数据:指定要调用的程序上的指令,以及指令所需的任何附加数据(函数参数)

根据你要调用的程序,可能有一些 crate 提供了用于构建指令的辅助函数。 然后,程序使 用solana_program crate 中的以下函数之一执行 CPI:

  • invoke —— 当没有 PDA 签名者时使用
  • invoke_signed —— 当调用程序需要使用从其程序 ID 派生的 PDA 进行签名时使用

基础 CPI

invoke函数用于进行不需要 PDA 签名者的 CPI。 在进行 CPI 时,提供给调用程序的签名者自动扩 展到被调用程序。

pub fn invoke(
    instruction: &Instruction,
    account_infos: &[AccountInfo<'_>]
) -> Result<(), ProgramError>

这是一个在 Solana Playground 上的示例程序,该程序使用invoke函数调用系统程序上的转账指令。你也可以参 考基础 CPI 指南了解更多细节。 你也可 以参考基础 CPI 指南了解更多细节。

带 PDA 签名者的 CPI

invoke_signed函 数用于进行需要 PDA 签名者的 CPI。 用于派生签名者 PDA 的种子作为signer_seeds传 递给invoke_signed函数。

你可以参考程序派生地址页面了解 PDA 的派生方式。

pub fn invoke_signed(
    instruction: &Instruction,
    account_infos: &[AccountInfo<'_>],
    signers_seeds: &[&[&[u8]]]
) -> Result<(), ProgramError>

运行时使用授予调用程序的权限来确定可以扩展到被调用程序的权限。 在此上下文中,权 限指的是签名者和可写账户。 例如,如果调用程序正在处理的指令包含签名者或可写账 户,那么调用程序可以调用另一个也包含了该签名者和/或可写账户的指令。

虽然 PDAs 没有私钥 ,但它们仍然可以通过 CPI 在指令中充当签名者。为了验证 PDA 是从调用程序派生的,生成 PDA 所用的种子必须作 为signers_seeds包含在内。 为了验证 PDA 是从调用程序派生的,生成 PDA 所用的种子 必须作为signers_seeds包含在内。

当 CPI 被处理时,Solana 运行时使用signers_seeds和调用程序的program_id 进 行内部调用create_program_address。 如果找到有效的 PDA,该地址 将被添加为有效签名者 。

这是一个在 Solana Playground 上的示例程序,该程序使用invoke_signed函数调用系统程序上的转账指令,并带有 PDA 签名者。 你可以参 考带 PDA 签名者的 CPI 指南了 解更多细节。

Solana核心概念-程序派生地址 (PDA)

程序派生地址 (PDAs) 为 Solana 上的开发者提供了两个主要用例:

  • 确定性账户地址: PDAs 提供了一种机制,通过组合可选的“种子”(预定义输入)和 特定的程序 ID 来确定性地派生一个地址。
  • 启用程序签名: Solana 运行时允许程序为从其程序 ID 派生的 PDAs “签名”。

你可以将 PDAs 视为一种通过预定义的一组输入(例如字符串、数字和其他账户地址)在链 上创建类似哈希映射结构的方法。
这种方法的好处是消除了跟踪确切地址的需要。 相反,你只需记住用于派生地址的特定输 入。

重要的是要理解,简单地派生一个程序派生地址(PDA)并不会自动在该地址创建一个链上 账户。具有 PDA 作为链上地址的账户必须通过用于派生地址的程序显式创建。 你可以将派 生 PDA 视为在地图上找到一个地址。 仅仅拥有一个地址并不意味着在该位置有构建内任何 内容。

本节将介绍派生 PDAs 的详细信息。 有关程序如何使用 PDAs 进行签名的详细信息将 在跨程序调用(CPIs) 一节中介绍,因为它需要这两个概念的上 下文。

关键点

  • PDAs 是使用用户定义的种子、一个 bump 种子和程序 ID 的组合确定性派生的地址。
  • PDAs 是落在 Ed25519 曲线之外的地址,没有对应的私钥。
  • Solana 程序可以以编程方式代表使用其程序 ID 派生的 PDA 进行“签名”。
  • 派生 PDA 并不会自动创建链上账户。
  • 使用 PDA 作为地址的账户必须通过 Solana 程序中的专用指令显式创建。

什么是 PDA

PDAs 是确定性派生的地址,看起来像标准的公钥,但没有关联的私钥。 这意味着没有外部 用户可以为该地址生成有效的签名。 然而,Solana 运行时允许程序以编程方式为 PDAs“签 名”而无需私钥。

作为背景,Solana Keypairs 是 Ed25519 曲线(椭圆曲线加密)上的点,具有公钥和对应的私钥。 我们通常使用公钥作 为新链上账户的唯一 ID,并使用私钥进行签名。

PDA 是一个通过预定义的一组输入故意派生到 Ed25519 曲线之外的点。 一个不在 Ed25519 曲线上的点没有有效的对应私钥,不能用于加密操作(签名)。

然后,PDA 可以用作链上账户的地址(唯一标识符),提供一种轻松存储、映射和获取程序 状态的方法。

如何派生 PDA

派生 PDA 需要 3 个输入。

  • 可选种子:用于派生 PDA 的预定义输入(例如字符串、数字、其他账户地址)。这 些输入被转换为字节缓冲区。 这些输入被转换为字节缓冲区。
  • Bump 种子:一个附加输入(值在 255-0 之间),用于保证生成有效的 PDA(曲线 外)。 生成 PDA 时,将 bump 种子(从 255 开始)附加到可选种子,以将点“推离 ”Ed25519 曲线。 bump 种子有时被称为“nonce”。
  • 程序 ID:PDA 派生自的程序地址。 这也是可以代表 PDA“签名”的程序。

下面的示例包括链接到 Solana Playground,你可以在浏览器编辑器中运行这些示例。

FindProgramAddress

要派生 PDA,我们可以使用 findProgramAddressSync 方法,该方法来自 @solana/web3.js。其他编程语言 (例如 Rust) 中也有此函数的等价物,但在本节中,我们将通过 Javascript 示例进行讲解。其他编程语 言(例如 Rust) 中也有此函数的等价物,但在本节中,我们将通过 Javascript 示例进行讲解。

使用findProgramAddressSync方法时,我们传入:

  • 转换为字节缓冲区的预定义可选种子,以及
  • 用于派生 PDA 的程序 ID(地址)

一旦找到有效的 PDA,findProgramAddressSync将返回派生 PDA 的地址(PDA)和 bump 种子。

下面的示例在没有提供任何可选种子的情况下派生 PDA。

import { PublicKey } from "@solana/web3.js";

const programId = new PublicKey("11111111111111111111111111111111");

const [PDA, bump] = PublicKey.findProgramAddressSync([], programId);

console.log(`PDA: ${PDA}`);
console.log(`Bump: ${bump}`);

你可以在 Solana Playground 上 运行此示例。 PDA 和 bump 种子的输出将始终相同:

Running client...
  client.ts:
    PDA: Cu7NwqCXSmsR5vgGA3Vw9uYVViPi3kQvkbKByVQ8nPY9
    Bump: 255

下面的示例添加了一个可选种子"helloWorld"。

import { PublicKey } from "@solana/web3.js";

const programId = new PublicKey("11111111111111111111111111111111");
const string = "helloWorld";

const [PDA, bump] = PublicKey.findProgramAddressSync(
  [Buffer.from(string)],
  programId,
);

console.log(`PDA: ${PDA}`);
console.log(`Bump: ${bump}`);

你也可以在 Solana Playground 上运行此示例。 PDA 和 bump 种子的输出将始终相同:

PDA: 46GZzzetjCURsdFPb7rcnspbEMnCBXe9kpjrsZAkKb6X
Bump: 254

注意,bump 种子是 254。这意味着 255 派生了 Ed25519 曲线 上的一个点,并不是一个有效的 PDA。
findProgramAddressSync返回的 bump 种子是给定可选种子和程序 ID 组合的第一个值 (在 255-0 之间),该值派生了一个有效的 PDA。

这个第一个有效的 bump 种子被称为“规范 bump”。 为了程序安全,建议在使用 PDAs 时 仅使用规范 bump。

CreateProgramAddress

在底层,findProgramAddressSync将迭代地将附加的 bump 种子(nonce)附加到种子缓 冲区,并调用 createProgramAddressSync 方法。bump 种子从 255 开始,每次减少 1,直到找到有效的 PDA(曲线外)

你可以通过使用createProgramAddressSync并显式传入 254 的 bump 种子来复制前面的 示例

import { PublicKey } from "@solana/web3.js";

const programId = new PublicKey("11111111111111111111111111111111");
const string = "helloWorld";
const bump = 254;

const PDA = PublicKey.createProgramAddressSync(
  [Buffer.from(string), Buffer.from([bump])],
  programId,
);

console.log(`PDA: ${PDA}`);

规范 Bump 在 Solana Playground 上运行上述示例。给定相同的种子和程序 ID,PDA 输出将与前一个匹配:

PDA: 46GZzzetjCURsdFPb7rcnspbEMnCBXe9kpjrsZAkKb6X

规范 Bump

“规范 bump”是指派生有效 PDA 的第一个 bump 种子(从 255 开始,每次减少 1)。 为了 程序安全,建议仅使用从规范 bump 派生的 PDAs。

以之前的示例为参考,下面的示例尝试使用从 255 到 0 的每个 bump 种子派生 PDA。

import { PublicKey } from "@solana/web3.js";

const programId = new PublicKey("11111111111111111111111111111111");
const string = "helloWorld";

// Loop through all bump seeds for demonstration
for (let bump = 255; bump >= 0; bump--) {
  try {
    const PDA = PublicKey.createProgramAddressSync(
      [Buffer.from(string), Buffer.from([bump])],
      programId,
    );
    console.log("bump " + bump + ": " + PDA);
  } catch (error) {
    console.log("bump " + bump + ": " + error);
  }
}

在 Solana Playground 上运行该 示例,你应该会看到以下输出:

Running client...
  client.ts:
    bump 255: Error: Invalid seeds, address must fall off the curve
    bump 254: 46GZzzetjCURsdFPb7rcnspbEMnCBXe9kpjrsZAkKb6X
    bump 253: GBNWBGxKmdcd7JrMnBdZke9Fumj9sir4rpbruwEGmR4y
    bump 252: THfBMgduMonjaNsCisKa7Qz2cBoG1VCUYHyso7UXYHH
    bump 251: EuRrNqJAofo7y3Jy6MGvF7eZAYegqYTwH2dnLCwDDGdP
    bump 250: Error: Invalid seeds, address must fall off the curve
    bump 249: Es18AFZ4N7U4aBKTVvq5GzpUxWseFzxxQuvpZaxne38Y
    bump 248: 8VzFABVVp8RRTzhmCpeMgYo8t3CYERtVpi646BabfV18
    ...

正如预期的那样,bump seed 255 会抛出错误,第一个导出有效 PDA 的 bump seed 是 254。

但是,请注意 bump seeds 253-251 都会导出具有不同地址的有效 PDA。 这意味着在给定 相同的可选种子和 programId 的情况下,具有不同值的 bump seed 仍然可以导出有效的 PDA。

在构建 Solana 程序时,建议包括安全检查,以验证传递给程序的 PDA 是使用规范的 bump 导出的。如果不这样做,可能会引入漏洞,允许向程序提供意外的账户。

创建 PDA 账户

这个在 Solana Playground 上的示例程序演示了如何使用 PDA 作为新账户的地址来创建账户。 示例程序是使用 Anchor 框架编写的。

在 lib.rs 文件中,你会发现以下程序,其中包括一个使用 PDA 作为账户地址创建新账 户的指令。 新账户存储了 user 的地址和用于导出 PDA 的 bump seed。

use anchor_lang::prelude::*;

declare_id!("75GJVCJNhaukaa2vCCqhreY31gaphv7XTScBChmr1ueR");

#[program]
pub mod pda_account {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        let account_data = &mut ctx.accounts.pda_account;
        // store the address of the `user`
        account_data.user = *ctx.accounts.user.key;
        // store the canonical bump
        account_data.bump = ctx.bumps.pda_account;
        Ok(())
    }
}

#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(mut)]
    pub user: Signer<'info>,

    #[account(
        init,
        // set the seeds to derive the PDA
        seeds = [b"data", user.key().as_ref()],
        // use the canonical bump
        bump,
        payer = user,
        space = 8 + DataAccount::INIT_SPACE
    )]
    pub pda_account: Account<'info, DataAccount>,
    pub system_program: Program<'info, System>,
}

#[account]

#[derive(InitSpace)]
pub struct DataAccount {
    pub user: Pubkey,
    pub bump: u8,
}

用于导出 PDA 的种子包括硬编码字符串 data 和指令中提供的 user 账户的地址。 Anchor 框架会自动导出规范的 bump seed。

#[account(
    init,
    seeds = [b"data", user.key().as_ref()],
    bump,
    payer = user,
    space = 8 + DataAccount::INIT_SPACE
)]
pub pda_account: Account<'info, DataAccount>,

init 约束指示 Anchor 调用系统程序,使用 PDA 作为地址创建新账户。 在底层,这是 通过 CPI 完成的。

#[account(
    init,
    seeds = [b"data", user.key().as_ref()],
    bump,
    payer = user,
    space = 8 + DataAccount::INIT_SPACE
)]
pub pda_account: Account<'info, DataAccount>,

在上述 Solana Playground 链接中的测试文件 (pda-account.test.ts) 中,你会找到等 效的 Javascript 代码来导出 PDA。

const [PDA] = PublicKey.findProgramAddressSync(
  [Buffer.from("data"), user.publicKey.toBuffer()],
  program.programId,
);

然后发送一个交易来调用 initialize 指令,使用 PDA 作为地址创建一个新的链上账 户。 交易发送后,使用 PDA 来获取在该地址创建的链上账户。

it("Is initialized!", async () => {
  const transactionSignature = await program.methods
    .initialize()
    .accounts({
      user: user.publicKey,
      pdaAccount: PDA,
    })
    .rpc();

  console.log("Transaction Signature:", transactionSignature);
});

it("Fetch Account", async () => {
  const pdaAccount = await program.account.dataAccount.fetch(PDA);
  console.log(JSON.stringify(pdaAccount, null, 2));
});

请注意,如果使用相同的 user 地址作为种子多次调用 initialize 指令,则交易将失 败。 这是因为在导出的地址上已经存在一个账户。

Solana 核心概念-程序

在 Solana 生态系统中,“智能合约”被称为程序。 每 个程序都是一个链上账户,存储可执行逻 辑,组织成特定的函数,称 为指令(instructions) 。

关键点

  • 程序是包含可执行代码的链上账户。 此代码组织成不同函数,称为指令。
  • 程序是无状态的,但可以包含创建新账户的指令,用于存储和管理程序状态。
  • 程序可以由升级权限更新。 当升级权限设置为 null 时,程序变为不可变。
  • 可验证的构建使用户能够验证链上程序是否与公开的源代码相匹配。

编写 Solana 程序

Solana 程序主要用 Rust 编程语言编写,开发有两 种常见方法:

  • Anchor:专为 Solana 程序开发设计的框架。它提供了一种更快、更简单的编写程序的方式,使用 Rust 宏来显著减少样板代码。对于初学者,建议从 Anchor 框架开始。
  • Native Rust:这种方法涉 及在不使用任何框架的情况下用 Rust 编写 Solana 程序。 它提供了更多的灵活性,但 也增加了复杂性。

更新 Solana 程序

链上程序可以由指定为“升级权限”的账 户直接修改 ,通常是最初部署程序的账户。
如果升级权限被 撤销并设置为None,程序将变为不可变,无法再更新。

可验证的程序

确保链上代码的完整性和可验证性至关重要。 可验证的构建确保部署在链上的可执行代码 可以由任何第三方独立验证,以匹配其公开的源代码。 此过程增强了透明度和信任,使得 能够检测源代码与部署程序之间的差异。

Solana 开发者社区推出了支持可验证构建的工具,使开发者和用户能够验证链上程序是否准确反映其公开共享的源代码。

  • 搜索已验证的程序:要快速检查已验证的程序,用户可以在 SolanaFM 浏览器上搜索程序地址并导航到“Verification”选项 卡。 在这里查 看一个已验证程序的示例。
  • 验证工具: Ellipsis Labs 的Solana 可验证构建 CLI 使用户能够根据已发布的源代码独立验证链上程序。
  • Anchor 中对可验证构建的支持:Anchor 提供了对可验证构建的内置支持。 详情请 参阅 Anchor 文档 。

伯克利包过滤器 (BPF)

Solana 利用 LLVM 编译器基础设施将程序编译 成可执行和链接格式 (ELF) 文件。 这些文件包括一个修改版 的伯克利包过滤器 (eBPF) 字节码,用于Solana 程序,称为“Solana 字节码格式” (sBPF)。

使用 LLVM 使 Solana 能够潜在支持任何可以编译到 LLVM 的 BPF 后端的编程语言。 这显 著增强了 Solana 作为开发平台的灵活性。

Solana 核心概念-费用

Solana 区块链有几种不同类型的费用和成本,这些费用和成本是使用无许可网络所需的。 它们可以分为几种特定类型:

  • 交易费用 - 验证者处理交易/指令的费用
  • 优先费用 - 提高交易处理顺序的可选费用
  • 租金 - 保持链上数据存储的保留余额

交易费用

在 Solana 区块链上的链上程序中处理逻辑(指令)所支付的小额费用称为“交易费用”。
当每个交易 (包含一个或多 个指令 )通过网络发送时,它会由当前的 验证者领导者处理。一旦确认为全局状态交易,这个 交易费用 将支付给网络,以帮助支 持 Solana 区块链的经济设计。

交易费用不同于账户数据存储押金费租金 。虽然交易费用是支付给 Solana 网 络处理指令的费用,但租金押金是保存在账户中以在区块链上存储其数据并可回收的费 用。 如上所述,每笔交易费用的固定比例被 燃烧(销毁)。这是为了巩固 SOL 的经 济价值,从而维持网络的安全性。与完全销毁交易费用的方案不同,领导者仍然有动力在 其时隙(创建区块的机会)中包含尽可能多的交易。

目前,Solana 的基本交易费用设定为每个签名 5k lamports 的固定值。 在这个基本费用 之上,可以添加任何额外的优先费用 。

为什么要支付交易费用?

交易费用在 Solana 的经济设计中提供了许多好处,主要包 括:

  • 为验证者网络提供补偿,以支付处理交易所需的 CPU/GPU 计算资源
  • 通过引入交易的实际成本来减少网络垃圾邮件
  • 通过每笔交易的协议捕获最低费用金额,为网络提供长期经济稳定性

基本经济设计

许多区块链网络(包括比特币和以太坊)依赖于通胀性 基于协议的奖励 来在短期内保护 网络。从长期来看,这些网络将越来越依赖 交易费用 来维持安全性。
在 Solana 上也是如此。具体来说: 具体而言:

  • 每笔交易费用的固定比例(最初为 50%)被 燃烧(销毁),其余部分归当前处理交易 的领导者所有。
  • 预定的全球通胀率 为奖励提 供了一个来源,分配给 Solana 验证者 。

费用收取

交易需要至少有一个账户签署交易并且是可写的。 这些 可写签名账户 首先在账户列表 中序列化,并且第一个总是用作“费用支付者”。

在处理任何交易指令之前,费用支付者账户 的余额将被扣除以 支付交易费用。 如果费用支付者余额不足以支付交易费用,交易处理将停止并导致交易失 败。

如果余额足够,费用将被扣除,交易的指令将开始执行。 如果任何指令导致错误,交易处 理将停止,并最终在 Solana 账本中记录为失败交易。 对于这些失败的交易,费用仍然由 运行时收取。

如果任何指令返回错误或违反运行时限制,所有账户更改除了交易费用扣除将被回 滚。 这是因为验证者网络已经花费了计算资源来收集交易并开始初步处理。

和以太坊基本一致

费用分配

交易费用 被部分销毁 ,剩余费用由生成包含相应交易的区块的验证者收取。 具体来 说,50%被销毁 ,50%分配给 生成区块的验证者。

为什么要销毁部分费用?

如上所述,每笔交易费用的固定比例被 燃烧(销毁)。 这是为了巩固 SOL 的经济价 值,从而维持网络的安全性。 与完全销毁交易费用的方案不同,领导者仍然有动力在其时 隙(创建区块的机会)中包含尽可能多的交易。
销毁的费用还可以帮助防止恶意验证者审查交易,通过 在分叉考虑实施。

攻击示例

在具有恶意或审查领导者 的历史证明(PoH) 分叉的情况下

  • 由于审查导致的费用损失,我们预计总销毁费用将 少于 可比的诚实分叉
  • 如果审查领导者要补偿这些丢失的协议费用,他们将不得不自己替换其分叉上的销毁费用
  • 从而可能减少最初的审查动机

计算交易费用

给定交易的完整费用基于两个主要部分计算

  • 每个签名的静态设定基本费用,以及
  • 交易期间使用的计算资源,以“计算单元”为 单位衡量

由于每个交易可能需要不同数量的计算资源,每个交易作为 计算预算 的一部分被分配了 每个交易的最大 计算单元 数量。

计算预算

在一个交易中链上执行的所有操作都需要消耗不同数量的计算资源(计算成本),这些资源 由验证者在处理时消耗。消耗这些资源的最小单位称为 "计算单元"。 为了防止滥用计算 资源,每个交易被分配一个“计算预算”。该预算指定了有 关计算单元的详细信息,包括:

  • 与交易可能执行的不同类型操作相关的计算成本(每个操作消耗的计算单元),
  • 交易可以消耗的最大计算单元数量(计算单元限制),
  • 以及交易必须遵守的操作界限(如账户数据大小限制)

当交易消耗其整个计算预算(计算预算耗尽)或超过某个界限(如尝试超 过最大调用堆栈深度或最大加载账户数 据大小限制)时,运行时将停止交易处理并返回错误。导致交易失败且没有状态更改(除了 交易费用被收取 )。 导致交易失败且没有状态更改(除了交易费用 被收取 )。

账户数据大小限制

一个交易可以通过包含一个 SetLoadedAccountsDataSizeLimit 指令来指定它允许加载的 账户数据的最大字节数(不超过运行时的绝对最大值)。 如果没有提供 SetLoadedAccountsDataSizeLimit,交易将默认使用运行时的 MAX_LOADED_ACCOUNTS_DATA_SIZE_BYTES值。

可以使用 ComputeBudgetInstruction::set_loaded_accounts_data_size_limit 函数来 创建这个指令:

let instruction = ComputeBudgetInstruction::set_loaded_accounts_data_size_limit(100_000);

计算单元

在一个交易中链上执行的所有操作都需要消耗不同数量的计算资源(计算成本),这些资源 由验证者在处理时消耗。消耗这些资源的最小单位称为 "计算单元"。 消耗这些资源的最 小单位称为 "计算单元"。

当一个交易被处理时,每个在链上执行的指令都会逐步消耗计算单元(消耗预算)。 由于 每个指令执行的逻辑不同(写入账户、cpi、执行系统调用等),每个指令可能会消 耗不同数量的 计算单元。

每个交易都有一个 计算单元限制,可以是运行时设置的默认限 制,也可以是显式请求的更高限制。一旦交易超过其计算单元限制,其处理将停止,导致交 易失败。 一旦交易超过其计算单元限制,其处理将停止,导致交易失败。

以下是一些常见的会产生计算成本的操作:

  • 执行指令
  • 在程序之间传递数据
  • 执行系统调用
  • using sysvars
  • 使用 msg! 宏记录日志
  • 记录公钥
  • 创建程序地址(PDA)
  • 跨程序调用(CPI)
  • 加密操作

对于 跨程序调用,被调用的指令继承其父级的计算预算和限制。 如果一个被调用的指令消耗了交易的剩余预算,或超过了限制,整个调用链和顶层交易处 理将停止。

对于 跨程序调用,被调用的指令继承其父级的计算预算和限制。 如果一个被调用的指令消耗了交易的剩余预算,或超过了限制,整个调用链和顶层交易处 理将停止。

你可以在 Solana 运行时的 ComputeBudget 中找到更多关于所有消耗计算单元的操作的详细信息。

计算单元限制

每个交易都有一个最大计算单元数(CU)称为 "计算单元限制"。 每个交易,Solana 运 行时有一个绝对最大计算单元限制为 140 万 CU 并设置了一个默认请求的最大限制为 每个指令 20 万 CU。

一个交易可以通过包含一个 SetComputeUnitLimit 指令来请求一个更具体和优化的计算 单元限制。 可以是更高或更低的限制。 但它永远不能请求超过每个交易的绝对最大限制。

虽然交易的默认计算单元限制在大多数情况下适用于简单交易,但它们通常不够优化(对于 运行时和用户)。对于更复杂的交易,如调用执行多个 CPI 的程序,你可能需要为交易请 求更高的计算单元限制。 对于更复杂的交易,如调用执行多个 CPI 的程序,你可能需要为 交易请求更高的计算单元限制。

为你的交易请求最佳计算单元限制对于帮助你支付更少的交易费用和更好地在网络上调度你 的交易至关重要。 钱包、dApps 和其他服务应确保其计算单元请求是最佳的,以提供尽可 能好的用户体验。

计算单元价格

当一个交易希望支付更高的费用以提升其处理优先级时,它可以设置一个 "计算单元价 格"。 这个价格与 计算单元限制 结合使用,将用于确定交易的 优先级费用。

默认情况下,没 有设置计算单元价格因 此没有额外的优先级费用。

优先级费用

作为计算预算的一部分,运行时支持交易支付一个可选费用,称 为 "优先级费用"。支付这个额外费用有助于提升交易在处理时的优先级,从而实现更快 的执行时间。

如何计算优先级费用

一个交易的优先级费用通过将其计算单元限制乘以计算单元价格(以 微 lamports 为单位)来计算。这些值可以通过包含以下计算预算指令在每个交易中设置一 次:这些值可以通过包含以下计算预算指令在每个交易中设置一次:

  • SetComputeUnitLimit - 设置交易可以消耗的最大计算单元数
  • SetComputeUnitPrice - 设置交易愿意支付的额外费用以提升其优先级

如果没有提供 SetComputeUnitLimit 指令,将使用 默认计算单元限制。
如果没有提供 SetComputeUnitPrice 指令,交易将默认没有额外的提升费用和最低优先 级(即没有优先级费用)。

如何设置优先级费用

一个交易的优先级费用通过包含一个 SetComputeUnitPrice 指令来设置,并可选地包含 一个 SetComputeUnitLimit 指令。 运行时将使用这些值来计算优先级费用,这将用于在 区块内优先处理给定的交易。

你可以通过其 Rust 或 @solana/web3.js 函数来创建这些指令。 然后可以将每个指令包含 在交易中并像正常一样发送到集群。 另请参阅下面 的最佳实践。

与 Solana 交易中的其他指令不同,计算预算指令不需要任何账户。包含多个相同类型 指令的交易将失败。 包含多个相同类型指令的交易将失败。

Rust

Rust 的 solana-sdk crate 包含在 ComputeBudgetInstruction 中的函数,用于设置 compute unit limit 和 compute unit price 的指令

let instruction = ComputeBudgetInstruction::set_compute_unit_limit(300_000);
let instruction = ComputeBudgetInstruction::set_compute_unit_price(1);

Javascript

The @solana/web3.js library includes functions within the ComputeBudgetProgram class to craft instructions for setting the compute unit limit and compute unit price:

const instruction = ComputeBudgetProgram.setComputeUnitLimit({
  units: 300_000,
});
const instruction = ComputeBudgetProgram.setComputeUnitPrice({
  microLamports: 1,
});

优先费用最佳实践

以下是关于优先费用最佳实践的一般信息。 你还可以在本指南中找到更多关 于如何请求最佳计算的 详细信息,包括如何模拟交易以确定其大致计算使用量。

请求最少的计算单元

交易应请求执行所需的最少计算单元以最小化费用。 还要注意,当请求的计算单元数量超 过实际消耗的计算单元数量时,费用不会调整。

获取最近的优先费用

在将交易发送到集群之前,你可以使用 getRecentPrioritizationFees RPC 方法获取节点处理的最近区块中支付的优先费用列表。
然后你可以使用这些数据来估算适当的优先费用,以便 (a) 更好地确保交易被集群处理, 并且 (b) 最小化支付的费用。

租金

存入每个 Solana 账户 以保持其关联数据在链上可用的费用 称为“租金”。 此费用在每个账户的正常 lamport 余额中扣留,并在账户关闭时可回收。

租金不同于交易费用 。 租金是“支付”(在账户中扣留)以保持 数据存储在 Solana 区块链上,并且可以回收。 而交易费用是支付给网络上处 理指令 的费用。

所有账户都需要保持足够高的 lamport 余额(相对于其分配的空间)以成 为租金豁免并留在 Solana 区块链上。任何试图将账户余额减少到其相应 的租金豁免最低余额以下的交易将失败(除非余额减少到正好为零)。 Solana 上的新账户 (和程序)必须初始化足够的 lamports 以成为 租金豁免。情况并非总是如此。以 前,运行时会定期自动从低于其 租金豁免的最低余额 的每个账户中收取费用。最终将这 些账户减少到零余额并从全局状态中垃圾回收它们(除非手动补充)。

当账户的所有者不再希望将此数据保留在链上并在全局状态中可用时,所有者可以关闭账户 并回收租金存款。

这是通过将账户的全部 lamport 余额提取(转移)到另一个账户(即你的钱包)来完成 的。 通过将账户余额减少到正好为 0,运行时将在“ 垃圾回收 ”过程中从网络中删除账户及其关联数据。

租金费率

Solana 的租金费率在整个网络范围内设置,主要基于运行时设置的“ 每字节每年 lamports”。 目前,租金费率是一个静态金额,并存储 在Rent sysvar 中。

此租金费率用于计算在账户内扣留的租金确切金额,以分配给账户的空间(即可以存储在账 户中的数据量)。账户分配的空间越多,扣留的租金存款就越高。 账户分配的空间越多, 扣留的租金存款就越高。

租金豁免

账户必须维持高于链上存储其各自数据所需的最低金额的 Lamport 余额。这被称为“免租”,该余额被称为“免租最低余额”。
Solana 上的新账户(和程序)必须初始化足够的 lamports 以成为 租金豁免。情 况并非总是如此。以前,运行时会定期自动从低于其 租金豁免的最低余额 的每个账户 中收取费用。 最终将这些账户减少到零余额并从全局状态中垃圾回收它们(除非手动补 充)。

在创建新账户的过程中,你必须确保存入足够的 lamports 以超过此最低余额。 任何低于 此最低阈值的金额将导致交易失败。

每次账户余额减少时,运行时都会检查账户是否仍高于此租金豁免的最低余额。由于审查导 致的费用损失,我们预计总销毁费用将少于可比的诚实分叉

账户成为租金豁免的具体最低余额取决于区块链的当前租金费率和账户希望 分配的存储空间量(账户大小)。 因此,建议使 用getMinimumBalanceForRentExemption RPC 端点来计算给定账户大小的具体余额。

所需的租金存款金额也可以通过 solana rent CLI 子命令估 算:

solana rent 15000

# output
Rent per byte-year: 0.00000348 SOL
Rent per epoch: 0.000288276 SOL
Rent-exempt minimum: 0.10529088 SOL

垃圾回收

未保持大于零的 lamport 余额的账户将在称为垃圾回收的过程中从网络中删除。 此过程有 助于减少网络范围内不再使用/维护的数据存储。

在交易成功将账户余额减少到正好为 0 后,垃圾回收会由运行时自动进行。 任何试图将 账户余额减少到其租金豁免最低余额以下(不正好为零)的交易将失败。

重要的是要注意垃圾回收在交易执行完成之后发生。 如果有一个指令通过将账户余额 减少到零来“关闭”账户,则可以在同一交易中通过后续指令“重新打开”账户。 如果账户状 态在“关闭”指令中未被清除,则后续的“重新打开”指令将具有相同的账户状态。 这是一个 安全问题,因此了解垃圾回收生效的确切时间非常重要。

即使账户已从网络中删除(通过垃圾回收),它仍可能有与其地址相关的交易(无论是过去 的历史还是未来的交易)。 即使 Solana 区块浏览器可能显示“找不到账户”类型的消息, 你仍然可以查看与该账户相关的交易历史。

你可以阅读验证 器已实现的提案了 解更多关于垃圾回收的信息。