本文档描述了跨链查询(CCQ)的设计方案,旨在提供一种机制,让集成者可以请求信息并从守护者那里获得关于他们所连接链的证明。CCQ 允许链上和链下发起信息请求,支持 EVM 链和 Solana 链,通过 REST API 接收和执行请求,并提供 Typescript 库和 EVM 响应解析库来辅助集成。同时,文章还讨论了安全性和部署方面的考虑。
为集成者提供一种机制,用于请求信息并从与其连接的链的守护者那里接收证明。
截至 2023 年 10 月,Wormhole 目前仅支持“推送”证明。 例如,为了获取以太坊上某个合约的状态,需要编写智能合约并将其部署到以太坊,明确读取该状态并在核心桥上调用 publishMessage
。 此外,每次在其他地方需要数据时,都需要进行成本高昂且耗时的交易。
此设计提出了一种“拉取”证明的机制。 希望能够在链上或链下发起这些请求。 但是,CCQ 的初始版本仅支持链下请求。
latest
、safe
、finalized
等)解析为特定的区块哈希或编号Wormhole 守护者运行许多连接链的完整节点。 在当前设计中,如果希望从其中一个链上的另一个链使用任何信息,则必须由数据所在的链上的专门开发的合约“推送”。 这会导致交易延迟,以及在该链上执行该交易的成本。 对于可能只需要按需跨链证明状态更改的应用程序,始终发布消息的额外复杂性和成本效率低下。
考虑一下使用跨链查询后,来自以太坊的 Token Bridge 的 token 证明会有何不同。 无需进行以太坊交易到 token bridge 以调用给定 tokenAddress
的 decimals()
、symbol()
和 name()
,并等待该交易的最终确认,而是可以通过守护者对以太坊上的该合约进行这三个调用的跨链查询。 跨链查询可能明显更快(可能只需几秒而不是 15-20 分钟),并且避免了在以太坊上支付 gas 的需要。
CCQ 功能增加了集成者向守护者网络提交查询请求并接收来自守护者的经过认证的响应的能力。 守护者监听这些请求,将它们提交到适当的链,从链接收结果,然后将它们发布回 gossip 网络。 然后,集成者可以累积这些请求并关联结果(应用法定人数),并确保结果的准确性。
守护者节点是 CCQ 功能的核心。 它们将负责接收来自集成者的请求(链上和链下),针对相应的 RPC 节点执行请求,并返回结果。
此外,为了方便链下请求,将存在一个 CCQ 查询服务器,该服务器将提供一个 REST 端点来接收和执行针对守护者网络的请求。 可以有多个 CCQ 查询服务器,每个服务器都有自己的 peer ID 和签名钱包,这些签名钱包需要在守护者上进行允许列表。
下图显示了基于浏览器的应用程序的查询请求的基本流程。
CCQ 将作为 guardiand
中的一个可选组件运行。 如果未配置,则不会启用。 守护者将独立处理查询请求。 与观察不同,守护者不会监听或处理来自彼此的 CCQ 流量。 每个守护者将收到请求,对其进行处理,如果有效,则执行查询并发布结果。 如果请求无效,守护者将直接丢弃该请求而不做出响应。
请求格式是可扩展的,以便支持跨异构链查询数据和批量处理请求,以最大限度地减少 gossip 流量和 RPC 开销。
截至 2024 年 2 月,CCQ 的当前版本支持 EVM 链和 Solana。 但是,该软件可以扩展到其他链,例如 CosmWasm 等。
守护者将通过 P2P gossip 监听链下请求。 为了最大限度地减少开销,CCQ 将使用与现有 gossip 分开的 P2P 信道。 这样,任何未启用 CCQ 的守护者,以及监听 gossip 的其他应用程序(例如 spies 和 flies)都不会看到任何 CCQ 流量。
CCQ 信道将使用两个主题,ccq_req
和 ccq_resp
。 守护者将订阅两者,但仅加入 ccq_req
。 这意味着守护者将看到所有请求,但看不到任何响应。
守护者将使用 P2P WithPeerFilter
和 RegisterTopicValidator
过滤器来仅接受来自允许列表中的 peer 的 P2P 流量。
守护者将监听 ccq_req
主题上的请求。 收到请求后,将将其提交到查询模块进行处理。
CCQ 的初始版本将不支持链上请求。
可以通过新的跨链查询合约在受支持的链上发起请求。 该合约可以构造一个表示请求者和请求的 payload,并通过核心桥发布它,从而生成标准的 VAA。 守护者可以有一个预定义的这些发射器列表,以将其视为跨链查询请求并相应地处理这些请求。
查询模块将执行请求验证,包括以下内容:
请注意,对于 eth_call
查询,block_id
必须是区块号或区块哈希。 不支持 latest
或 finalized
等标签。 这是因为不同的守护者对于 latest
或 finalized
可能具有不同的值,具体取决于其节点的状态。
请注意,可能需要支持使用 latest
和 finalized
等标签,这可能需要 gossip 区块号或让查询服务器读取数据。 这将作为后续功能处理。
请注意,对于 eth_call_by_timestamp
查询,必须指定 timestamp
。 此外,可以指定 hint_target_block_id
和 hint_following_block_id
以帮助守护者确定查询范围。 请注意,如果指定了其中一个,则必须同时指定两者。 提示的格式与 eth_call
中的 block_id
相同。
请注意,对于 eth_call_with_finality
查询,必须指定 finality
。 唯一有效的值是 finalized
和 safe
。 block_id
是必需的,并且具有与 eth_call
中相同的格式。
请求消息必须在 payload 中包含签名,以便区分请求者和(可能,第三方)p2p 中继器。 该签名应该使用单独的密钥创建。
为了区分签名并防止重放攻击,即从 mainnet 重放用于 devnet/testnet 的请求,必须在签名时使用以下前缀。 这些前缀字符串填充为 35 字节,与 守护者密钥用法 和现有代码保持一致。
mainnet_query_request_000000000000|
testnet_query_request_000000000000|
devnet_query_request_0000000000000|
截至 2024 年 1 月,正在添加 Solana 查询的实验性实现。 此实现被认为是实验性的,因为 Solana 本机不支持读取特定 slot number 的帐户数据,这意味着每个 guardiand 观察者将返回其最新 slot 版本的数据,这可能使其难以达成共识。 该计划是将其部署到 mainnet,以便我们可以尝试各种方法来实现共识。
验证请求后,查询模块会将各个每个链的查询请求提交到相应的观察者以供执行。 观察者会将 RPC 调用提交到节点并返回结果。 查询模块和观察者之间的通信是通过每个观察者的一对 golang 信道进行的,一个入站和一个出站。 观察者将使用批量请求来最大限度地减少 RPC 开销。 为了使此方法有效,集成者应将 RPC 调用正确分组到最少的每个链查询集中。
对于 eth_call_with_finality
请求,观察者将不会返回结果,直到请求的区块达到所需的最终性级别。 此外,在不发布安全区块的链上,对最终性 "safe" 的请求将被视为 "finalized",而不是引发错误。
查询模块将侦听所有每个链查询的响应。 收到所有每个链的响应后,该模块将发布结果以在 gossip 网络上发布。 如果任何响应失败或超时,查询模块将定期重试最多一分钟。 如果在一分钟后某些每个链的查询未成功,则查询模块将放弃该请求。
请注意,守护者不会响应错误的请求,以最大限度地减少 DoS 攻击媒介。 如果他们确实响应,恶意用户可能会用错误的请求轰炸 gossip 网络,这将导致每个请求的众多错误响应成倍增加。 CCQ 查询服务器会请求验证,并在检测到错误的请求时以错误响应。
当且仅当请求已成功处理时,守护者才将使用 ccq_resp
主题通过 P2P 发布查询响应消息。 如前所述,其他守护者不会看到此消息。 只有 REST 服务器以及可能的第三方集成者会看到它。
查询响应包含初始查询请求和结果。 请求的存在允许集成者验证响应是否是他们期望的。
响应应使用前缀 query_response_0000000000000000000|
进行签名。 请注意,没有必要为每个环境使用不同的响应前缀,因为响应使用守护者密钥签名,该密钥在不同环境之间是不同的。
CCQ 的守护者配置将包含以下配置参数。
ccqEnabled
- 如果设置为 true
,则启用 CCQ 功能。 默认为 false。ccqAllowedRequesters
- 允许提交查询请求的签名者公钥的逗号分隔列表。 没有默认值。ccqP2pPort
- 用于绑定 CCQ P2P 信道的本地端口,默认为 8996
。ccqP2pBootstrap
- CCQ P2P 信道的引导 peer。 没有默认值(但在 tilt 中自动生成)。ccqAllowedPeers
- 允许提交查询请求的 P2P peer ID 的逗号分隔列表。为了减轻守护者节点的存储负担,完整的响应不会持久存储在守护者中。 但是,为了方便去重和授权,一些跨链查询信息可能会提交到网关 (Wormchain)。
一些集成者可能会选择通过编码自己的查询请求并将其发布到 gossip 网络来直接与守护者通信。 这样做将需要在其 P2P peer ID 在守护者上进行配置。 此外,他们需要有一个 P2P 库,这限制了他们的选择(例如,目前没有用于 P2P 的 typescript 库)。 此外,集成者需要收集和整合响应。
由于这些和其他原因,正在提供一个单独的 REST 服务器来接受链下查询请求,将其提交到 gossip 网络,关联响应,并将其返回给调用者。
CCQ 服务器将配置为在 CCQ P2P 网络上发布和侦听。 它的 P2P peer ID 将在守护者处注册。 服务器将同时加入 ccq_req
和 ccq_resp
,但仅订阅 ccq_resp
。
服务器将配置为支持的用户列表。 每个用户将具有一个 API 密钥,以及他们被允许执行的查询列表。 每个用户的查询数据将如下所示:
{
"userName": "Test User",
"apiKey": "432185ac-fbb7-4883-b68d-888e7bd749a9",
"allowUnsigned": true,
"allowedCalls": [
{
"ethCall": {
"note:": "Name of WETH on Goerli",
"chain": 2,
"contractAddress": "B4FBF271143F4FBf7B91A5ded31805e42b2208d6",
"call": "0x06fdde03"
}
}
]
}
当用户提交查询时,他们需要在请求中设置 X-API-Key
标头。
服务器将仅接受来自已配置用户的请求。 这些请求可能仅用于 allowedCalls
列表中配置的 RPC 调用。
每个允许的调用由以下指定:
所有配置的用户都可以提交他们使用自己的密钥签名的查询。 除了签名请求外,如果 allowUnsigned
标志设置为 true
,则用户可以提交未签名的请求,服务器将使用预配置的密钥对其进行签名。 请注意,所有密钥必须在守护者允许列表中。
为了帮助集成者,sdk/js-query
中有一个 typescript 库,该库提供了用于构建查询的方法。 此外,sdk/js-query/src/query/ethCall.test.ts
中有一些用法示例。
收到响应后,集成者需要验证和解析它以获取结果。 有一个简单的 EVM 库可以简化此过程,该库可以在 ethereum/contracts/query/QueryResponse.sol
中找到。 一个用法示例在 ethereum/forge-test/query/Query.t.sol
中。
QueryResponse
解析库使用 Wormhole Core
合约来验证响应上的签名。 这目前涉及进行四个跨合约调用,从而浪费 gas。 一个理想的优化是向 Wormhole
合约添加一个新的公共方法,该方法将采用 payload(响应字节)和签名集,并在一次调用中执行验证。 如果签名无效,它应该回滚。
u8 version
u32 nonce
u8 num_per_chain_queries
[]byte per_chain_queries
可以在单个 Per-Chain Query
中提交同一链的多个查询。
u16 chain_id
u8 type
uint32 query_len
[]byte query_data
目前,EVM 上支持的查询类型是 eth_call
、eth_call_by_timestamp
和 eth_call_with_finality
。 这可以扩展以支持其他协议。
eth_call(查询类型 1)
u32 block_id_len
[]byte block_id
u8 num_batch_call_data
[]byte batch_call_data
调用被批处理以允许指定对同一区块的多个调用。 这些将在同一批次 RPC 调用中完成,并且更容易在链上进行请求者的验证。
[20]byte contract_address
u32 call_data_len
[]byte call_data
eth_call_by_timestamp(查询类型 2)
此查询类型类似于 eth_call
,但针对的是时间戳而不是特定 block_id。 当基于不相关的数据形成请求时,这可能很有用,例如基于给定链的区块时间戳请求来自另一个链的数据。
请求必须包含目标时间戳。
在 CCQ 的初始版本中,请求必须包含目标区块和后续区块的区块提示。 守护者使用这些提示来查找区块。
截至 2023 年 12 月,区块提示是可选的,因为守护者维护区块时间戳到区块号的缓存。 保证此缓存覆盖最近 30 分钟的区块,并在启动时进行回填。 随着新区块的进入,它们会被添加到缓存中,因此它很可能覆盖超过 30 分钟。(维护最新的 10000 个区块。)如果收到的请求没有提示,并且时间戳早于缓存中最旧的条目,则该请求将被拒绝。
结果区块号必须相差 1
,并且它们的时间戳必须使得目标区块_早于_目标时间(包括),而后续区块_晚于_目标时间(不包括)。 换句话说,
target_block.timestamp <= target_time < following_block.timestamp
and
following_block_num - 1 == target_block_num
守护者代码必须在签署结果之前强制执行上述条件。
u64 target_time_us
u32 target_block_id_hint_len
[]byte target_block_id_hint
u32 following_block_id_hint_len
[]byte following_block_id_hint
u8 num_batch_call_data
[]byte batch_call_data
eth_call_with_finality(查询类型 3)
此查询类型类似于 eth_call
,但确保指定的区块在返回查询结果之前已达到指定的最终性。 最终性可以是“finalized”或“safe”。 请注意,只有在发布安全区块的链上才支持“safe”。 请求必须同时包含 block_id 和 finality。
守护者代码必须在签署结果之前强制执行最终性。
u32 block_id_len
[]byte block_id
u32 finality_len
[]byte finality
u8 num_batch_call_data
[]byte batch_call_data
目前,Solana 上支持的查询类型是 sol_account
和 sol_pda
。
sol_account(查询类型 4)- 此查询用于读取 Solana 上一个或多个帐户的数据。
u32 commitment_len
[]byte commitment
u64 min_context_slot
u64 data_slice_offset
u64 data_slice_length
u8 num_accounts
[][32]byte account_list
commitment
是必需的,目前必须是 finalized
。
min_context_slot
是可选的,用于指定可以评估请求的最小 slot。
data_slice_offset
和 data_slice_length
是可选的,用于指定应返回的帐户数据的部分。
account_list
指定要批处理到单个查询中的帐户列表。 列表中的每个帐户都是一个 Solana PublicKey
sol_pda(查询类型 5)- 此查询用于基于其程序派生地址读取 Solana 上一个或多个帐户的数据。
u32 commitment_len
[]byte commitment
u64 min_context_slot
u64 data_slice_offset
u64 data_slice_length
u8 num_pdas
[]PdaList pda_list
commitment
是必需的,目前必须是 finalized
。
min_context_slot
是可选的,用于指定可以评估请求的最小 slot。
data_slice_offset
和 data_slice_length
是可选的,用于指定应返回的帐户数据的部分。
pda_list
指定批处理到单个查询中的程序派生地址列表。
PdaList
定义如下:
[32]byte program_address
u8 num_seeds
[]Seed seed_data (max of 16, per the Solana code)
每个 Seed
定义如下:
u32 seed_len (max of 32, per the Solana code)
[]byte seed
u8 version
u16 sender_chain_id = 0
[65]byte signature
u32 query_request_len
[]byte query_request
u8 num_per_chain_responses
[]byte per_chain_responses
u16 sender_chain_id != 0
[32]byte vaa_hash
[]byte query_request
u8 num_per_chain_responses
[]byte per_chain_responses
所有每个链的响应都以以下标头开头。
u16 chain_id
u8 type
uint32 response_len
[]byte response
eth_call(查询类型 1)响应正文
u64 block_number
[32]byte block_hash
u64 block_time_us
u8 num_results
[]byte results
u32 result_len
[]byte result
eth_call_by_timestamp(查询类型 2)响应正文
u64 target_block_number
[32]byte target_block_hash
u64 target_block_time_us
u64 following_block_number
[32]byte following_block_hash
u64 following_block_time_us
u8 num_results
[]byte results
u32 result_len
[]byte result
eth_call_with_finality(查询类型 3)响应正文
eth_call_with_finality
的响应与 eth_call
的响应相同,尽管查询类型将是 3 而不是 1。
sol_account(查询类型 4)响应正文
u64 slot_number
u64 block_time_us
[32]byte block_hash
u8 num_results
[]byte results
slot_number
是查询返回的 slot number。block_time_us
是与 slot 关联的区块的时间戳。block_hash
是与 slot 关联的区块哈希。results
数组返回每个查询的帐户的数据u64 lamports
u64 rent_epoch
u8 executable
[32]byte owner
u32 result_len
[]byte result
lamports
是分配给帐户的 lamports 数量。rent_epoch
是此帐户下次欠租的 epoch。executable
是一个布尔值,指示帐户是否包含程序(并且严格来说是只读的)。owner
是帐户所有者的公钥。result
是帐户查询返回的数据。sol_pda(查询类型 5)响应正文
u64 slot_number
u64 block_time_us
[32]byte block_hash
u8 num_results
[]byte results
slot_number
是查询返回的 slot number。block_time_us
是与 slot 关联的区块的时间戳。block_hash
是与 slot 关联的区块哈希。results
数组返回每个查询的 PDA 的数据[32]byte account
u8 bump
u64 lamports
u64 rent_epoch
u8 executable
[32]byte owner
u32 result_len
[]byte result
account
是从 PDA 派生的帐户地址。bump
是 Solana 派生函数返回的 bump 值。lamports
是分配给帐户的 lamports 数量。rent_epoch
是此帐户下次欠租的 epoch。executable
是一个布尔值,指示帐户是否包含程序(并且严格来说是只读的)。owner
是帐户所有者的公钥。result
是帐户查询返回的数据。export interface QueryRequest {
bytes: string; // As a hex string
signatures?: string; // As a hex string
}
请注意,如果用户已配置为允许 CCQ 服务器配置中的未签名请求,则可以省略签名。 在这种情况下,CCQ 服务器将对请求进行签名。
请求必须包含以下标头。
200 - 请求在 < 1 分钟内达成共识
export interface QueryResponse {
bytes: string; // As a hex string
signatures?: string; // As a hex string
}
400 - 错误的请求(格式错误的输入或字节)
401 - 需要授权(缺少 API 密钥)
403 - 禁止(无效的 API 密钥)
500 - [将来] 未能达成共识(例如,收到 14 个响应,但 7 个具有一个结果,7 个具有另一个结果)
504 - 未在 < 1 分钟内达成共识
针对 testnet 守护者的测试可能无法准确读取收集共识或命中生产节点 - 只有一个守护者,并且它依赖于一些公共和第三方提供商节点。
出于演示目的,已设置一个独立的 GCP VM 来托管守护者和 REST 服务器。
正在执行以下操作以减少拒绝服务威胁面:
请注意,为了防止请求在守护者的 RPC 节点上产生不必要的负载,可能需要一种机制来限制速率或对请求者收取服务费。
通过为每个环境(devnet 与 testnet 与 mainnet)使用单独的签名密钥,无法跨环境重放请求。
仅允许配置的钱包签名请求。
此外,只有配置的 API 密钥才能向 REST 端点提交请求。
- 原文链接: github.com/wormhole-foun...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!