BCSkill (Block chain skill )
区块链中文技术社区

只讨论区块链底层技术
遵守一切相关法律政策!

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 作为开发平台的灵活性。