变更记录

  • 26-11-2020:初始草案
  • 26-05-2023:在 02-client 重构以及 Strangelove 重新实现后更新
  • 13-12-2023:在模块上游合并到 ibc-go 后更新

状态

已接受,并已在 08-wasm 的 v0.1.0 中应用

摘要

在 Cosmos SDK 中,轻客户端当前是以 Go 代码硬编码实现的。这使得升级现有 IBC 轻客户端,或为新的轻客户端添加支持,都变成一个涉及链上治理的多步骤流程,既耗时又低效。 为了解决这个问题,我们提议使用一个 Wasm VM 来承载轻客户端字节码,从而能够更容易地升级现有 IBC 轻客户端,并在不需要发布代码版本和对应硬分叉事件的情况下,为新的 IBC 轻客户端添加支持。

背景

目前在 ibc-go 中,轻客户端被定义为代码库的一部分,并作为 modules/light-clients 下的模块实现。无论是为新的轻客户端添加支持,还是在发生安全问题或共识升级时更新现有轻客户端,都是一个多步骤流程,既耗时又容易出错。为了启用新的 IBC 轻客户端实现,需要修改 ibc-go 的代码库(如果该轻客户端属于其代码库的一部分)、重新构建各链的二进制文件、通过治理提案,并由验证者升级他们的节点。 由上述流程带来的另一个问题是,如果某条链想升级自身共识,它需要说服所有与其连接的链或枢纽同步升级对应的轻客户端,才能保持连接。由于升级轻客户端所需流程较为耗时,一条拥有大量连接的链在升级其共识后,可能需要在相当长一段时间内处于断连状态,这在时间和精力成本上都可能非常高昂。 我们提议通过集成一个 Wasm 轻客户端模块来简化这一工作流,使得为新轻客户端添加支持只需一次受治理控制的简单交易。使用可编译为 Wasm 的 Rust 编写的轻客户端字节码,会在 Wasm VM 中运行。Wasm 轻客户端子模块暴露出一个代理轻客户端接口,将传入消息路由到 Wasm VM 内部相应的处理函数执行。 有了 Wasm 轻客户端模块,任何人都可以以 Wasm 字节码的形式添加新的 IBC 轻客户端(前提是他们能够提交治理提案交易且提案获得通过),也可以使用任何已创建的客户端类型实例化客户端。这使得任何链都可以在其他链上更新自己的轻客户端,而无需经历上述步骤。

决策

我们决定将 Wasm 轻客户端模块实现为一个轻客户端代理,由它与实际上传为 Wasm 字节码的轻客户端交互。为了启用 Wasm 轻客户端模块,用户需要通过更新 core IBC 中 02-client 子模块的 AllowedClients 参数,将其加入允许的客户端列表。
params := clientKeeper.GetParams(ctx)
params.AllowedClients = append(params.AllowedClients, exported.Wasm)
clientKeeper.SetParams(ctx, params)
新增轻客户端合约受到治理控制。要上传新的轻客户端,用户需要提交一个包含用于存储 Wasm 合约字节码的 sdk.Msg 的治理 v1 提案。所需消息为 MsgStoreCode,字节码通过 wasm_byte_code 字段提供:
// MsgStoreCode defines the request type for the StoreCode rpc.
message MsgStoreCode {
  // signer address
  string signer = 1;
  // wasm byte code of light client contract. It can be raw or gzip compressed
  bytes wasm_byte_code = 2;
}
处理 MsgStoreCode 的 RPC 处理器会确保消息签名者与被授权提交该消息的地址一致(通常就是治理模块的地址)。
// StoreCode defines a rpc handler method for MsgStoreCode
func (k Keeper) StoreCode(goCtx context.Context, msg *types.MsgStoreCode) (*types.MsgStoreCodeResponse, error) {
  if k.GetAuthority() != msg.Signer {
    return nil, errorsmod.Wrapf(ibcerrors.ErrUnauthorized, "expected %s, got %s", k.GetAuthority(), msg.Signer)
  }

  ctx := sdk.UnwrapSDKContext(goCtx)
  checksum, err := k.storeWasmCode(ctx, msg.WasmByteCode, ibcwasm.GetVM().StoreCode)
  if err != nil {
    return nil, errorsmod.Wrap(err, "failed to store wasm bytecode")
  }

  emitStoreWasmCodeEvent(ctx, checksum)

  return &types.MsgStoreCodeResponse{
    Checksum: checksum,
  }, nil
}
合约字节码本身不会存储在状态中(实际上没有必要存储,而且会造成浪费,因为 Wasm VM 已经保存了它,并且在需要时可以再次查询)。校验和只是该合约字节码的哈希值,它会以键为 checksums 的条目存储在状态中,该条目包含所有已存储字节码的校验和。

轻客户端代理如何工作?

轻客户端代理在底层会调用一个 CosmWasm 智能合约实例,并将传入参数连同适当的环境信息一起序列化为 JSON 格式。智能合约返回的数据会被反序列化后返回给调用方。 以 ClientState 接口中的 VerifyClientMessage 函数为例。传入参数会被封装进一个 payload 对象,然后序列化为 JSON 并传递给 queryContract,后者执行 WasmVm.Query 并返回智能合约返回的字节切片。该数据随后会被反序列化,并作为返回参数传出。
type QueryMsg struct {
  Status               *StatusMsg               `json:"status,omitempty"`
  ExportMetadata       *ExportMetadataMsg       `json:"export_metadata,omitempty"`
  TimestampAtHeight    *TimestampAtHeightMsg    `json:"timestamp_at_height,omitempty"`
  VerifyClientMessage  *VerifyClientMessageMsg  `json:"verify_client_message,omitempty"`
  CheckForMisbehaviour *CheckForMisbehaviourMsg `json:"check_for_misbehaviour,omitempty"`
}

type verifyClientMessageMsg struct {
  ClientMessage *ClientMessage `json:"client_message"`
}

// VerifyClientMessage must verify a ClientMessage. 
// A ClientMessage could be a Header, Misbehaviour, or batch update.
// It must handle each type of ClientMessage appropriately. 
// Calls to CheckForMisbehaviour, UpdateStaåte, and UpdateStateOnMisbehaviour
// will assume that the content of the ClientMessage has been verified
// and can be trusted. An error should be returned
// if the ClientMessage fails to verify.
func (cs ClientState) VerifyClientMessage(
  ctx sdk.Context,
  _ codec.BinaryCodec,
  clientStore storetypes.KVStore,
  clientMsg exported.ClientMessage
) error {
  clientMessage, ok := clientMsg.(*ClientMessage)
  if !ok {
    return errorsmod.Wrapf(ibcerrors.ErrInvalidType, "expected type: %T, got: %T", &ClientMessage{}, clientMsg)
  }

  payload := QueryMsg{
    VerifyClientMessage: &VerifyClientMessageMsg{ClientMessage: clientMessage.Data},
  }
  _, err := wasmQuery[EmptyResult](ctx, clientStore, &cs, payload)
  return err
}

全局 Wasm VM 变量

08-wasm keeper 结构体会保留一个对 Wasm VM 的引用,该 VM 在 keeper 构造函数中完成实例化。keeper 使用 Wasm VM 来存储轻客户端合约的字节码。不过,08-wasm 中对 ClientState 接口某些函数的实现,同样需要 Wasm VM 来初始化合约、对合约执行调用以及查询合约。由于这些 ClientState 函数无法访问 08-wasm keeper,因此决定保留一个全局指针变量,使其指向与 08-wasm keeper 中相同的实例。随后,这个全局指针变量会在 ClientState 函数的实现中使用。

影响

正面影响

  • 为新的轻客户端添加支持或升级现有轻客户端比以前容易得多,只需要一次交易,而不再需要硬分叉。
  • 提高了 ibc-go 的可维护性,因为支持新客户端或升级客户端都不需要修改代码库。
  • 轻客户端可以使用 Rust 依赖,而这些依赖在 Go 中可能并不存在相应支持。

负面影响

  • 用 Rust 编写的轻客户端必须使用能够编译为 Wasm 的 Rust 子集来实现。
  • 轻客户端代码较难分析,因为区块链上只存在编译后的字节码。

Changelog

  • 26-11-2020: Initial Draft
  • 26-05-2023: Update after 02-client refactor and re-implementation by Strangelove
  • 13-12-2023: Update after upstreaming of module to ibc-go

Status

Accepted and applied in v0.1.0 of 08-wasm

Abstract

In the Cosmos SDK light clients are currently hardcoded in Go. This makes upgrading existing IBC light clients or adding support for new light client a multi step process involving on-chain governance which is time-consuming. To remedy this, we are proposing a Wasm VM to host light client bytecode, which allows easier upgrading of existing IBC light clients as well as adding support for new IBC light clients without requiring a code release and corresponding hard-fork event.

Context

Currently in ibc-go light clients are defined as part of the codebase and are implemented as modules under modules/light-clients. Adding support for new light clients or updating an existing light client in the event of a security issue or consensus update is a multi-step process which is both time-consuming and error-prone. In order to enable new IBC light client implementations it is necessary to modify the codebase of ibc-go (if the light client is part of its codebase), re-build chains’ binaries, pass a governance proposal and validators upgrade their nodes. Another problem stemming from the above process is that if a chain wants to upgrade its own consensus, it will need to convince every chain or hub connected to it to upgrade its light client in order to stay connected. Due to the time-consuming process required to upgrade a light client, a chain with lots of connections needs to be disconnected for quite some time after upgrading its consensus, which can be very expensive in terms of time and effort. We are proposing simplifying this workflow by integrating a Wasm light client module that makes adding support for new light clients a simple governance-gated transaction. The light client bytecode, written in Wasm-compilable Rust, runs inside a Wasm VM. The Wasm light client submodule exposes a proxy light client interface that routes incoming messages to the appropriate handler function, inside the Wasm VM for execution. With the Wasm light client module, anybody can add new IBC light client in the form of Wasm bytecode (provided they are able to submit the governance proposal transaction and that it passes) as well as instantiate clients using any created client type. This allows any chain to update its own light client in other chains without going through the steps outlined above.

Decision

We decided to implement the Wasm light client module as a light client proxy that will interface with the actual light client uploaded as Wasm bytecode. To enable usage of the Wasm light client module, users need to add it to the list of allowed clients by updating the AllowedClients parameter in the 02-client submodule of core IBC.
params := clientKeeper.GetParams(ctx)
params.AllowedClients = append(params.AllowedClients, exported.Wasm)
clientKeeper.SetParams(ctx, params)
Adding a new light client contract is governance-gated. To upload a new light client users need to submit a governance v1 proposal that contains the sdk.Msg for storing the Wasm contract’s bytecode. The required message is MsgStoreCode and the bytecode is provided in the field wasm_byte_code:
// MsgStoreCode defines the request type for the StoreCode rpc.
message MsgStoreCode {
  // signer address
  string signer = 1;
  // wasm byte code of light client contract. It can be raw or gzip compressed
  bytes wasm_byte_code = 2;
}
The RPC handler processing MsgStoreCode will make sure that the signer of the message matches the address of authority allowed to submit this message (which is normally the address of the governance module).
// StoreCode defines a rpc handler method for MsgStoreCode
func (k Keeper) StoreCode(goCtx context.Context, msg *types.MsgStoreCode) (*types.MsgStoreCodeResponse, error) {
  if k.GetAuthority() != msg.Signer {
    return nil, errorsmod.Wrapf(ibcerrors.ErrUnauthorized, "expected %s, got %s", k.GetAuthority(), msg.Signer)
  }

  ctx := sdk.UnwrapSDKContext(goCtx)
  checksum, err := k.storeWasmCode(ctx, msg.WasmByteCode, ibcwasm.GetVM().StoreCode)
  if err != nil {
    return nil, errorsmod.Wrap(err, "failed to store wasm bytecode")
  }

  emitStoreWasmCodeEvent(ctx, checksum)

  return &types.MsgStoreCodeResponse{
    Checksum: checksum,
  }, nil
}
The contract’s bytecode is not stored in state (it is actually unnecessary and wasteful to store it, since the Wasm VM already stores it and can be queried back, if needed). The checksum is simply the hash of the bytecode of the contract and it is stored in state in an entry with key checksums that contains the checksums for the bytecodes that have been stored.

How light client proxy works?

The light client proxy behind the scenes will call a CosmWasm smart contract instance with incoming arguments serialized in JSON format with appropriate environment information. Data returned by the smart contract is deserialized and returned to the caller. Consider the example of the VerifyClientMessage function of ClientState interface. Incoming arguments are packaged inside a payload object that is then JSON serialized and passed to queryContract, which executes WasmVm.Query and returns the slice of bytes returned by the smart contract. This data is deserialized and passed as return argument.
type QueryMsg struct {
  Status               *StatusMsg               `json:"status,omitempty"`
  ExportMetadata       *ExportMetadataMsg       `json:"export_metadata,omitempty"`
  TimestampAtHeight    *TimestampAtHeightMsg    `json:"timestamp_at_height,omitempty"`
  VerifyClientMessage  *VerifyClientMessageMsg  `json:"verify_client_message,omitempty"`
  CheckForMisbehaviour *CheckForMisbehaviourMsg `json:"check_for_misbehaviour,omitempty"`
}

type verifyClientMessageMsg struct {
  ClientMessage *ClientMessage `json:"client_message"`
}

// VerifyClientMessage must verify a ClientMessage. 
// A ClientMessage could be a Header, Misbehaviour, or batch update.
// It must handle each type of ClientMessage appropriately. 
// Calls to CheckForMisbehaviour, UpdateStaåte, and UpdateStateOnMisbehaviour
// will assume that the content of the ClientMessage has been verified
// and can be trusted. An error should be returned
// if the ClientMessage fails to verify.
func (cs ClientState) VerifyClientMessage(
  ctx sdk.Context,
  _ codec.BinaryCodec,
  clientStore storetypes.KVStore,
  clientMsg exported.ClientMessage
) error {
  clientMessage, ok := clientMsg.(*ClientMessage)
  if !ok {
    return errorsmod.Wrapf(ibcerrors.ErrInvalidType, "expected type: %T, got: %T", &ClientMessage{}, clientMsg)
  }

  payload := QueryMsg{
    VerifyClientMessage: &VerifyClientMessageMsg{ClientMessage: clientMessage.Data},
  }
  _, err := wasmQuery[EmptyResult](ctx, clientStore, &cs, payload)
  return err
}

Global Wasm VM variable

The 08-wasm keeper structure keeps a reference to the Wasm VM instantiated in the keeper constructor function. The keeper uses the Wasm VM to store the bytecode of light client contracts. However, the Wasm VM is also needed in the 08-wasm implementations of some of the ClientState interface functions to initialise a contract, execute calls on the contract and query the contract. Since the ClientState functions do not have access to the 08-wasm keeper, then it has been decided to keep a global pointer variable that points to the same instance as the one in the 08-wasm keeper. This global pointer variable is then used in the implementations of the ClientState functions.

Consequences

Positive

  • Adding support for new light client or upgrading existing light client is way easier than before and only requires single transaction instead of a hard-fork.
  • Improves maintainability of ibc-go, since no change in codebase is required to support new client or upgrade it.
  • The existence of support for Rust dependencies in light clients which may not exist in Go.

Negative

  • Light clients written in Rust need to be written in a subset of Rust which could compile in Wasm.
  • Introspecting light client code is difficult as only compiled bytecode exists in the blockchain.