# 10 分钟构建自己的区块链｜乌托邦周报 #21

By [无环图](https://paragraph.com/@0x257dfbc2836361d04fc4badcc3645a9bc63abdbf) · 2023-06-07

---

从早期的 ICO，到最近的 BRC-20、BRC-721，“人人发币”早已算不上什么新鲜事。

与其琢磨给自己的币种起名，设置流通量和规则，幻想有朝一日币价翻天，倒不如花 10 分钟，发一条完完整整的 Layer 1 区块链来得实在和酷。

作为《[Tendermint 导读](https://acyclicgraph.substack.com/p/introduction-to-tendermint)》的续篇，废话不多说，这次我们就来探探，Cosmos SDK 是如何带领大家走进“人人发链”时代的。

**本文要点：**

*   Cosmos SDK 架构概览
    
*   手把手写一个类 ENS 区块链
    

* * *

Cosmos SDK 架构概览
===============

Cosmos SDK 是基于 Tendermint（现 CometBFT）共识和网络层构建的区块链模块化开发框架。

![来源：https://v1.cosmos.network/intro](https://storage.googleapis.com/papyrus_images/5511895c86f382288961f1daa0c74b3bbeb945b02641404a2cde5fd6c41804f5.png)

来源：https://v1.cosmos.network/intro

回到《[Tendermint 导读](https://acyclicgraph.substack.com/p/introduction-to-tendermint)》中的第一张图，我们可以看到 Cosmos SDK 封装了很多模块，你最可能关心的包括：

*   Staking：负责管理质押和验证者，理解为 ETH 2.0 的 Staking 就好了。
    
*   Slashing：负责判别、处理验证者的违规行为，包括离线和 Double Sign 等。
    
*   Mint：负责定义区块奖励（Block Reward）逻辑，包括隔多久出块，每出一个区块奖励验证者多少 Token。
    
*   Bank：发币，可理解为 ERC-20 那套逻辑。
    
*   NFT：发币，但发的是 NFT，可理解为 ERC-721 那套逻辑。
    
*   Cosmwasm：智能合约能力，可以用 Rust 写。
    
*   Gov：负责定义治理逻辑，包括如何提交 Proposal、投票等。
    
*   IBC：跨链通信，在《[Into the Cosmoverse](https://acyclicgraph.substack.com/p/into-the-cosmoverse)》中有简单介绍。
    

因为这些模块都是开箱即用的，所以如果你对其中一些逻辑没有特别的要求，完全可以不作调整，只使用想要自定义逻辑的模块即可。

虽然 Cosmos SDK 大大降低了 Layer 1 的开发门槛，但笔者还是不鼓励生啃 [Cosmos SDK 文档](https://docs.cosmos.network/)（勇士除外）。虽然文档内容相比几年前已大为进步，但行文依然十分艰涩难懂。事无巨细一一交代，结果就是读者很容易迷失。

要快速入门，其实看文档中的这张图就好了。

![来源：https://docs.cosmos.network/v0.47/intro/sdk-design](https://storage.googleapis.com/papyrus_images/24b551c9ef1a026b572e086bc02f4bab973e7d1b555e8e0e0e3ee4af5cd0e812.png)

来源：https://docs.cosmos.network/v0.47/intro/sdk-design

整个 Cosmos SDK 有 2 层分发（这种模式在 Tendermint 中也有出现）：

*   第 1 层：Cosmos SDK 负责处理从 CometBFT 获取的 Tx，将其解析为 Message，然后将它分发到对应的模块处理。
    
*   第 2 层：模块收到 Message 后，根据 Message 类型，再分发到对应的方法处理。
    

其中，到了第 2 层，对应的处理方法里做的主要就是根据 Message 内容，来执行 State Transition，最终将更新后的 State 储存下来。

Cosmos SDK 的 State 储存也很简单，本质上就是一个 KV 存储。给定一个 Key，你就能读取和储存相应的信息。至于你是想存一个数组，一个映射，还是单纯一个值，全靠在 Key 上做文章。

上面说的是 Tx，即涉及 State Transition 的操作，需要广播，消耗 Gas，所有节点执行；还有一类操作叫 Query，Query 是纯粹的查询，不会修改 State，不消耗 Gas，只要一个全节点执行就好了。Query 类似于在 Etherscan 上你可以根据合约提供的 view 方法，提供一个输入，直接得到一个结果输出。

一个 Cosmos SDK 链的全节点会通过 RPC 的形式，暴露 Tx 和 Query 的接口。事实上，写好 Tx 和 Query 处理方法后，你可以遵循 Cosmos SDK 中提供的脚手架，来生成对应的 CLI，调用你的区块链提供的能力。

* * *

手把手写一个类 ENS 区块链
===============

如果上面说的还有点抽象，没关系，我们举一个实际的例子。

这里我们打算写一个幼儿园版的 ENS（Ethereum Name Service），叫做 CNS（Cosmos Name Service）。为了说明 Cosmos SDK 的使用方法，我们会简化业务逻辑，不会照搬 ENS 的功能，也不会像 ENS 那样用 NFT。

具体功能如下：

*   注册域名（RegisterDomain），域名指向所有者的地址，在注册期限内有效；
    
*   出售域名（ListForSale），采用挂牌价的方式；
    
*   停止出售域名（UnlistForSale）；
    
*   购买域名（BuyDomain）。
    

这里我们不支持域名续期和更全面的解析类型，因而整个逻辑骨架非常清晰。

下面我们就一步步实现这些功能需求。

### 安装 Cosmos SDK（1/6）

直接从 [GitHub](https://github.com/cosmos/cosmos-sdk) 上克隆下来就好。

本身目录非常多，我们只需关心以下几个就好：

*   /proto：各种结构体定义放这里。
    
*   /simapp：各种模块初始化，看 app.go。
    
*   /x：所有模块都在这里，我们要写的 CNS 模块也会放在这里。
    

### 定义结构体（2/6）

要定义的结构体包含 3 类：

*   State 结构体
    
*   Tx Message 涉及的请求和返回值结构
    
*   Query 涉及的请求和返回值结构
    

首先是 State 结构体，这里我们直接定义一个 Domain 就够用了。

    // /proto/cns/cns/domain.proto
    syntax = "proto3";
    package cns.cns;
    
    option go_package = "cns/x/cns/types";
    
    message Domain {
      string name = 1; // 域名本名，如 ag.cns。
      string owner = 2; // 所有者地址
      uint64 validThru = 3; // 有效期至哪个区块高度
      bool isForSale = 4; // 是否出售
      uint64 price = 5; // 挂牌价
    }
    

然后是 Tx Message 涉及的请求和返回值结构（简单起见，返回值都为空）。

    // /proto/cns/cns/tx.proto
    syntax = "proto3";
    
    package cns.cns;
    
    import "cns/cns/domain.proto";
    
    option go_package = "cns/x/cns/types";
    
    service Msg {
      rpc RegisterDomain (MsgRegisterDomain) returns (MsgRegisterDomainResponse);
      rpc ListForSale    (MsgListForSale   ) returns (MsgListForSaleResponse   );
      rpc UnlistForSale  (MsgUnlistForSale ) returns (MsgUnlistForSaleResponse );
      rpc BuyDomain      (MsgBuyDomain     ) returns (MsgBuyDomainResponse     );
    }
    
    message MsgRegisterDomain {
      string creator = 1; // Tx 的创建方地址，与 Domain 实体本身无关。
      string name    = 2; // 域名本名，如 ag.cns。
      uint64 years   = 3; // 买多少年
    }
    
    message MsgRegisterDomainResponse {}
    
    message MsgListForSale {
      string creator = 1; // Tx 的创建方地址，与 Domain 实体本身无关。
      string name    = 2; // 域名本名，如 ag.cns。
      uint64 price   = 3; // 挂牌价
    }
    
    message MsgListForSaleResponse {}
    
    message MsgUnlistForSale {
      string creator = 1; // Tx 的创建方地址，与 Domain 实体本身无关。
      string name    = 2; // 域名本名，如 ag.cns。
    }
    
    message MsgUnlistForSaleResponse {}
    
    message MsgBuyDomain {
      string creator = 1; // Tx 的创建方地址，与 Domain 实体本身无关。
      string name    = 2; // 域名本名，如 ag.cns。
    }
    
    message MsgBuyDomainResponse {}
    

最后是 Query 涉及的请求和返回值结构。这里支持查单个域名和列举全部域名。

    // /proto/cns/cns/query.proto
    syntax = "proto3";
    
    package cns.cns;
    
    import "gogoproto/gogo.proto";
    import "google/api/annotations.proto";
    import "cosmos/base/query/v1beta1/pagination.proto";
    import "cns/cns/params.proto";
    import "cns/cns/domain.proto";
    
    option go_package = "cns/x/cns/types";
    
    service Query {
      rpc Params (QueryParamsRequest) returns (QueryParamsResponse) {
        option (google.api.http).get = "/cns/cns/params";
      
      }
    
      rpc Domain (QueryGetDomainRequest) returns (QueryGetDomainResponse) {
        option (google.api.http).get = "/cns/cns/domain/{name}";
      
      }
      rpc DomainAll (QueryAllDomainRequest) returns (QueryAllDomainResponse) {
        option (google.api.http).get = "/cns/cns/domain";
      
      }
    }
    
    message QueryParamsRequest {}
    
    message QueryParamsResponse {
      Params params = 1 [(gogoproto.nullable) = false];
    }
    
    message QueryGetDomainRequest {
      string name = 1;
    }
    
    message QueryGetDomainResponse {
      Domain domain = 1 [(gogoproto.nullable) = false];
    }
    
    message QueryAllDomainRequest {
      cosmos.base.query.v1beta1.PageRequest pagination = 1;
    }
    
    message QueryAllDomainResponse {
      repeated Domain domain = 1 [(gogoproto.nullable) = false];
               cosmos.base.query.v1beta1.PageResponse pagination = 2;
    }
    

代码特别多，但多是口水代码。

### 定义域名的 CRUD 方法（3/6）

区块链本质是 Replicated State Machine，所以无非是对 State 中的一些实体的 CRUD。

这里我们分别定义 Domain 的增删改查方法，为后面打下基础。

按照惯例，这些方法放在 Keeper 下。遵循的范式是：

1.  获取 KV 存储对象；
    
2.  对对象作操作。
    

以“查”为例，获取 KV 存储对象用的函数为 `func NewStore(parent types.KVStore, prefix []byte) Store`。其中，参数`parent` 为父存储对象，这里我们为 CNS 模块开辟了一个独立的存储空间（特别的`storeKey`），用于隔离 Cosmos SDK 自带模块的存储；`prefix` 为 KV 中 Key 的前缀，这里我们定义为`/Domain/value/`。

获取存储对象后，根据域名（name）获取 Domain 对象。这里 DomainKey 做的是将`name`从`string`转为`[]byte`，并加一个`/` 后缀，以匹配`Get` 函数的参数类型要求。

拿到对应的 Domain 后，这个 Domain 还只是又一串`[]byte`，需要用 codec 反序列化（`MustUnmarshal`）再返回。

    // /x/cns/keeper/domain.go
    package keeper
    
    import (
        "cns/x/cns/types"
        "github.com/cosmos/cosmos-sdk/store/prefix"
        sdk "github.com/cosmos/cosmos-sdk/types"
    )
    
    // 查一个
    func (k Keeper) GetDomain(
        ctx sdk.Context,
        name string,
    
    ) (val types.Domain, found bool) {
        store := prefix.NewStore(ctx.KVStore(k.storeKey), types.KeyPrefix(types.DomainKeyPrefix))
    
        b := store.Get(types.DomainKey(
            name,
        ))
        if b == nil {
            return val, false
        }
    
        k.cdc.MustUnmarshal(b, &val)
        return val, true
    }
    

增、删、改同理，就不赘述了。

    // 增、改
    func (k Keeper) SetDomain(ctx sdk.Context, domain types.Domain) {
        store := prefix.NewStore(ctx.KVStore(k.storeKey), types.KeyPrefix(types.DomainKeyPrefix))
        b := k.cdc.MustMarshal(&domain)
        store.Set(types.DomainKey(
            domain.Name,
        ), b)
    }
    
    // 删
    func (k Keeper) RemoveDomain(
        ctx sdk.Context,
        name string,
    
    ) {
        store := prefix.NewStore(ctx.KVStore(k.storeKey), types.KeyPrefix(types.DomainKeyPrefix))
        store.Delete(types.DomainKey(
            name,
        ))
    }
    

### 定义 Tx Message 的处理方法（4/6）

CRUD 方法都有了，Tx Message 的处理方法就顺理成章了。

以注册域名（RegisterDomain）为例，我们先`GetDomain` 看看有没有注册过，有注册过的话看看注册有没有过期，如果有注册过且没过期就不让注册，否则就按照约定的价格（这里是 100 token 一年），为 Tx 的创建方注册上这个域名。

    // /x/cns/keeper/msg_server_domain_sales.go
    package keeper
    
    import (
        "context"
    
        "cns/x/cns/types"
    
        sdk "github.com/cosmos/cosmos-sdk/types"
        sdkerrors "github.com/cosmos/cosmos-sdk/types/errors"
    )
    
    const (
        blocksPerYear = 60 * 60 * 8766 / 5 // mintGenesis.Params.BlocksPerYear
        pricePerYear  = 100
    )
    
    func (k msgServer) RegisterDomain(goCtx context.Context, msg *types.MsgRegisterDomain) (*types.MsgRegisterDomainResponse, error) {
        ctx := sdk.UnwrapSDKContext(goCtx)
    
        valFound, isFound := k.GetDomain(
            ctx,
            msg.Name,
        )
    
        if isFound && valFound.ValidThru <= uint64(ctx.BlockHeight()) {
            return nil, sdkerrors.Wrap(sdkerrors.ErrKeyNotFound, "Domain already taken")
        }
    
        price := pricePerYear * msg.Years
        from, _ := sdk.AccAddressFromBech32(msg.Creator)
        if err := k.bankKeeper.SendCoinsFromAccountToModule(ctx, from, types.ModuleName, sdk.NewCoins(sdk.NewCoin("token", sdk.NewInt(int64(price))))); err != nil {
            return nil, sdkerrors.Wrap(sdkerrors.ErrInsufficientFunds, "Insufficient funds.")
        }
    
        newDomain := types.Domain{
            Owner:     msg.Creator,
            Name:      msg.Name,
            ValidThru: uint64(ctx.BlockHeight()) + blocksPerYear*msg.Years,
            IsForSale: false,
            Price:     0,
        }
    
        k.SetDomain(ctx, newDomain)
    
        return &types.MsgRegisterDomainResponse{}, nil
    }
    

出售域名、停止出售域名、购买域名也是类似。只不过：

*   检查的条件稍有不同；
    
*   购买域名时，直接将`price`对应的 token 从购买者账号转移到所有者账号中。
    

    func (k msgServer) ListForSale(goCtx context.Context, msg *types.MsgListForSale) (*types.MsgListForSaleResponse, error) {
        ctx := sdk.UnwrapSDKContext(goCtx)
    
        valFound, isFound := k.GetDomain(
            ctx,
            msg.Name,
        )
        if !isFound {
            return nil, sdkerrors.Wrap(sdkerrors.ErrKeyNotFound, "Domain not found")
        }
    
        if msg.Creator != valFound.Owner {
            return nil, sdkerrors.Wrap(sdkerrors.ErrUnauthorized, "Cannot list a domain you do not own")
        }
    
        valFound.IsForSale = true
        valFound.Price = msg.Price
    
        k.SetDomain(ctx, valFound)
    
        return &types.MsgListForSaleResponse{}, nil
    }
    
    func (k msgServer) UnlistForSale(goCtx context.Context, msg *types.MsgUnlistForSale) (*types.MsgUnlistForSaleResponse, error) {
        ctx := sdk.UnwrapSDKContext(goCtx)
    
        valFound, isFound := k.GetDomain(
            ctx,
            msg.Name,
        )
        if !isFound {
            return nil, sdkerrors.Wrap(sdkerrors.ErrKeyNotFound, "Domain not found")
        }
    
        if msg.Creator != valFound.Owner {
            return nil, sdkerrors.Wrap(sdkerrors.ErrUnauthorized, "Cannot unlist a domain you do not own")
        }
    
        valFound.IsForSale = false
    
        k.SetDomain(ctx, valFound)
    
        return &types.MsgUnlistForSaleResponse{}, nil
    }
    
    func (k msgServer) BuyDomain(goCtx context.Context, msg *types.MsgBuyDomain) (*types.MsgBuyDomainResponse, error) {
        ctx := sdk.UnwrapSDKContext(goCtx)
    
        valFound, isFound := k.GetDomain(
            ctx,
            msg.Name,
        )
    
        if !isFound {
            return nil, sdkerrors.Wrap(sdkerrors.ErrKeyNotFound, "Domain not found")
        }
    
        if valFound.ValidThru < uint64(ctx.BlockHeight()) {
            return nil, sdkerrors.Wrap(sdkerrors.ErrKeyNotFound, "Domain exists but expired. Try RegisterDomain.")
        }
    
        if !valFound.IsForSale {
            return nil, sdkerrors.Wrap(sdkerrors.ErrKeyNotFound, "Domain not for sale")
        }
    
        if msg.Creator == valFound.Owner {
            return nil, sdkerrors.Wrap(sdkerrors.ErrUnauthorized, "Cannot buy your own domain")
        }
        from, _ := sdk.AccAddressFromBech32(msg.Creator)
        to, _ := sdk.AccAddressFromBech32(valFound.Owner)
        if err := k.bankKeeper.SendCoins(ctx, from, to, sdk.NewCoins(sdk.NewCoin("token", sdk.NewInt(int64(valFound.Price))))); err != nil {
            return nil, sdkerrors.Wrap(sdkerrors.ErrInsufficientFunds, "Insufficient funds")
        }
    
        valFound.Owner = msg.Creator
        valFound.IsForSale = false
        k.SetDomain(ctx, valFound)
    
        return &types.MsgBuyDomainResponse{}, nil
    }
    

### 定义 Query 的处理方法（5/6）

Query 包括查一个域名和列举全部域名。知识点上面都有涉及，这里就不赘述了。

    // /x/cns/keeper/query_domain.go
    package keeper
    
    import (
        "context"
    
        "cns/x/cns/types"
        "github.com/cosmos/cosmos-sdk/store/prefix"
        sdk "github.com/cosmos/cosmos-sdk/types"
        "github.com/cosmos/cosmos-sdk/types/query"
        "google.golang.org/grpc/codes"
        "google.golang.org/grpc/status"
    )
    
    // 查一个
    func (k Keeper) Domain(goCtx context.Context, req *types.QueryGetDomainRequest) (*types.QueryGetDomainResponse, error) {
        if req == nil {
            return nil, status.Error(codes.InvalidArgument, "Invalid request")
        }
        ctx := sdk.UnwrapSDKContext(goCtx)
    
        val, found := k.GetDomain(
            ctx,
            req.Name,
        )
        if !found {
            return nil, status.Error(codes.NotFound, "Domain not found")
        }
    
        return &types.QueryGetDomainResponse{Domain: val}, nil
    }
    
    // 列举全部
    func (k Keeper) DomainAll(goCtx context.Context, req *types.QueryAllDomainRequest) (*types.QueryAllDomainResponse, error) {
        if req == nil {
            return nil, status.Error(codes.InvalidArgument, "Invalid request")
        }
    
        var domains []types.Domain
        ctx := sdk.UnwrapSDKContext(goCtx)
    
        store := ctx.KVStore(k.storeKey)
        domainStore := prefix.NewStore(store, types.KeyPrefix(types.DomainKeyPrefix))
    
        pageRes, err := query.Paginate(domainStore, req.Pagination, func(key []byte, value []byte) error {
            var domain types.Domain
            if err := k.cdc.Unmarshal(value, &domain); err != nil {
                return err
            }
    
            domains = append(domains, domain)
            return nil
        })
    
        if err != nil {
            return nil, status.Error(codes.Internal, err.Error())
        }
    
        return &types.QueryAllDomainResponse{Domain: domains, Pagination: pageRes}, nil
    }
    

### 更新 CLI

万事俱备，只差告诉 CLI 如何调用我们上面的 Tx 和 Query 能力了。

同样是口水代码，照抄一下自带模块的，改改就好了。

首先是 Tx 的 CLI 代码，这里以注册域名（RegisterDomain）为例：

    // /x/cns/client/cli/tx_register_domain.go
    package cli
    
    import (
        "cns/x/cns/types"
        "github.com/cosmos/cosmos-sdk/client"
        "github.com/cosmos/cosmos-sdk/client/flags"
        "github.com/cosmos/cosmos-sdk/client/tx"
        "github.com/spf13/cast"
        "github.com/spf13/cobra"
    )
    
    func CmdRegisterDomain() *cobra.Command {
        cmd := &cobra.Command{
            Use:   "register-domain [name] [years]",
            Short: "Broadcast message RegisterDomain",
            Args:  cobra.ExactArgs(2),
            RunE: func(cmd *cobra.Command, args []string) (err error) {
                argName := args[0]
                argYears, err := cast.ToUint64E(args[1])
                if err != nil {
                    return err
                }
    
                clientCtx, err := client.GetClientTxContext(cmd)
                if err != nil {
                    return err
                }
    
                msg := types.NewMsgRegisterDomain(
                    clientCtx.GetFromAddress().String(),
                    argName,
                    argYears,
                )
                if err := msg.ValidateBasic(); err != nil {
                    return err
                }
                return tx.GenerateOrBroadcastTxCLI(clientCtx, cmd.Flags(), msg)
            },
        }
    
        flags.AddTxFlagsToCmd(cmd)
    
        return cmd
    }
    

搞定后注册到 Tx 子命令下：

    // /x/cns/client/cli/tx.go
    package cli
    
    import (
        "fmt"
        "time"
    
        "github.com/spf13/cobra"
    
        "github.com/cosmos/cosmos-sdk/client"
        "cns/x/cns/types"
    )
    
    var (
        DefaultRelativePacketTimeoutTimestamp = uint64((time.Duration(10) * time.Minute).Nanoseconds())
    )
    
    const (
        flagPacketTimeoutTimestamp = "packet-timeout-timestamp"
        listSeparator              = ","
    )
    
    func GetTxCmd() *cobra.Command {
        cmd := &cobra.Command{
            Use:                        types.ModuleName,
            Short:                      fmt.Sprintf("%s transactions subcommands", types.ModuleName),
            DisableFlagParsing:         true,
            SuggestionsMinimumDistance: 2,
            RunE:                       client.ValidateCmd,
        }
    
        cmd.AddCommand(CmdRegisterDomain())
        cmd.AddCommand(CmdListForSale())
        cmd.AddCommand(CmdUnlistForSale())
        cmd.AddCommand(CmdBuyDomain())
    
        return cmd
    }
    

然后是 Query 对应的 CLI 代码：

    // /x/cns/client/cli/query_domain.go
    package cli
    
    import (
        "context"
    
        "cns/x/cns/types"
        "github.com/cosmos/cosmos-sdk/client"
        "github.com/cosmos/cosmos-sdk/client/flags"
        "github.com/spf13/cobra"
    )
    
    func CmdShowDomain() *cobra.Command {
        cmd := &cobra.Command{
            Use:   "show-domain [name]",
            Short: "shows a domain",
            Args:  cobra.ExactArgs(1),
            RunE: func(cmd *cobra.Command, args []string) (err error) {
                clientCtx := client.GetClientContextFromCmd(cmd)
    
                queryClient := types.NewQueryClient(clientCtx)
    
                argName := args[0]
    
                params := &types.QueryGetDomainRequest{
                    Name: argName,
                }
    
                res, err := queryClient.Domain(context.Background(), params)
                if err != nil {
                    return err
                }
    
                return clientCtx.PrintProto(res)
            },
        }
    
        flags.AddQueryFlagsToCmd(cmd)
    
        return cmd
    }
    
    func CmdListDomain() *cobra.Command {
        cmd := &cobra.Command{
            Use:   "list-domain",
            Short: "list all domain",
            RunE: func(cmd *cobra.Command, args []string) error {
                clientCtx := client.GetClientContextFromCmd(cmd)
    
                pageReq, err := client.ReadPageRequest(cmd.Flags())
                if err != nil {
                    return err
                }
    
                queryClient := types.NewQueryClient(clientCtx)
    
                params := &types.QueryAllDomainRequest{
                    Pagination: pageReq,
                }
    
                res, err := queryClient.DomainAll(context.Background(), params)
                if err != nil {
                    return err
                }
    
                return clientCtx.PrintProto(res)
            },
        }
    
        flags.AddPaginationFlagsToCmd(cmd, cmd.Use)
        flags.AddQueryFlagsToCmd(cmd)
    
        return cmd
    }
    

同样，搞定后要注册到 Query 子命令下：

    package cli
    
    import (
        "fmt"
    
        "github.com/spf13/cobra"
    
        "github.com/cosmos/cosmos-sdk/client"
    
        "cns/x/cns/types"
    )
    
    func GetQueryCmd(queryRoute string) *cobra.Command {
        cmd := &cobra.Command{
            Use:                        types.ModuleName,
            Short:                      fmt.Sprintf("Querying commands for the %s module", types.ModuleName),
            DisableFlagParsing:         true,
            SuggestionsMinimumDistance: 2,
            RunE:                       client.ValidateCmd,
        }
    
        cmd.AddCommand(CmdQueryParams())
        cmd.AddCommand(CmdListDomain())
        cmd.AddCommand(CmdShowDomain())
    
        return cmd
    }
    

验收一下
----

编译后，我们得到一个我们 Cosmos Name Service 区块链专属的 CLI，叫做 cnsd。下面我们来测试下功能。

首先，我们看到 Alice 账号下一开始有 20,000 token（在 genesis 文件中配置）：

    cnsd query bank balances $(cnsd keys show alice -a)
    balances:
    - amount: "100000000"
      denom: stake
    - amount: "20000"
      denom: token
    pagination:
      next_key: null
      total: "0"
    

_（大家可以留意一下 Query 和 Tx 命令的构成，比如后面都紧接着模块，再后面跟着 Query 的东西或 Tx Message 的类型，最后面是参数。）_

然后，Alice 注册了 2 年的 ag.cns 域名：

    cnsd tx cns register-domain ag.cns 2 --from alice
    auth_info:
      fee:
        amount: []
        gas_limit: "200000"
        granter: ""
        payer: ""
      signer_infos: []
      tip: null
    body:
      extension_options: []
      memo: ""
      messages:
      - '@type': /cns.cns.MsgRegisterDomain
        creator: cosmos1270k40m4rdd60huuf8uhyllql8wh3g6zppcwp5
        name: ag.cns
        years: "2"
      non_critical_extension_options: []
      timeout_height: "0"
    signatures: []
    confirm transaction before signing and broadcasting [y/N]: y
    ...
    raw_log: '[{"msg_index":0,"events":[{"type":"coin_received","attributes":[{"key":"receiver","value":"cosmos1sfa9p0xmn9685r5r8mundaw96r0qhd9p2akk39"},{"key":"amount","value":"200token"}]},{"type":"coin_spent","attributes":[{"key":"spender","value":"cosmos1270k40m4rdd60huuf8uhyllql8wh3g6zppcwp5"},{"key":"amount","value":"200token"}]},{"type":"message","attributes":[{"key":"action","value":"/cns.cns.MsgRegisterDomain"},{"key":"sender","value":"cosmos1270k40m4rdd60huuf8uhyllql8wh3g6zppcwp5"}]},{"type":"transfer","attributes":[{"key":"recipient","value":"cosmos1sfa9p0xmn9685r5r8mundaw96r0qhd9p2akk39"},{"key":"sender","value":"cosmos1270k40m4rdd60huuf8uhyllql8wh3g6zppcwp5"},{"key":"amount","value":"200token"}]}]}]'
    timestamp: ""
    tx: null
    txhash: 0A1C1AD27E3EED5610D957D904B886FF31B300FA0E674BD21F73ECC1833AFF22
    

不出所料，Alice 花了 200 Token：

    cnsd query bank balances $(cnsd keys show alice -a)
    balances:
    - amount: "100000000"
      denom: stake
    - amount: "19800"
      denom: token
    pagination:
      next_key: null
      total: "0"
    

并且 Alice 确认自己抢注成功：

    cnsd query cns show-domain ag.cns
    domain:
      isForSale: false
      name: ag.cns
      owner: cosmos1270k40m4rdd60huuf8uhyllql8wh3g6zppcwp5
      price: "0"
      validThru: "12625268"
    

Alice 觉得这个域名会很火，打算挂 500 Token 出售：

    cnsd tx cns list-for-sale ag.cns 500 --from alice
    auth_info:
      fee:
        amount: []
        gas_limit: "200000"
        granter: ""
        payer: ""
      signer_infos: []
      tip: null
    body:
      extension_options: []
      memo: ""
      messages:
      - '@type': /cns.cns.MsgListForSale
        creator: cosmos1270k40m4rdd60huuf8uhyllql8wh3g6zppcwp5
        name: ag.cns
        price: "500"
      non_critical_extension_options: []
      timeout_height: "0"
    signatures: []
    confirm transaction before signing and broadcasting [y/N]: y
    ...
    raw_log: '[{"msg_index":0,"events":[{"type":"message","attributes":[{"key":"action","value":"/cns.cns.MsgListForSale"}]}]}]'
    timestamp: ""
    tx: null
    txhash: F7AD84CD9E72AAD2819014E86F5FAB284B55FBE6D8B5C8BB4FC28087BB421702
    

Alice 确认 ag.cns 处于在售状态：

    cnsd query cns show-domain ag.cns                
    domain:
      isForSale: true
      name: ag.cns
      owner: cosmos1270k40m4rdd60huuf8uhyllql8wh3g6zppcwp5
      price: "500"
      validThru: "12625268"
    

Bob 看到了这个域名在售，兴高采烈来接盘：

    cnsd tx cns buy-domain ag.cns --from bob   
    auth_info:
      fee:
        amount: []
        gas_limit: "200000"
        granter: ""
        payer: ""
      signer_infos: []
      tip: null
    body:
      extension_options: []
      memo: ""
      messages:
      - '@type': /cns.cns.MsgBuyDomain
        creator: cosmos1yrxya30r2yknyfdm29hg7d75dys85p6dz39m6f
        name: ag.cns
      non_critical_extension_options: []
      timeout_height: "0"
    signatures: []
    confirm transaction before signing and broadcasting [y/N]: y
    ...
    raw_log: '[{"msg_index":0,"events":[{"type":"coin_received","attributes":[{"key":"receiver","value":"cosmos1270k40m4rdd60huuf8uhyllql8wh3g6zppcwp5"},{"key":"amount","value":"500token"}]},{"type":"coin_spent","attributes":[{"key":"spender","value":"cosmos1yrxya30r2yknyfdm29hg7d75dys85p6dz39m6f"},{"key":"amount","value":"500token"}]},{"type":"message","attributes":[{"key":"action","value":"/cns.cns.MsgBuyDomain"},{"key":"sender","value":"cosmos1yrxya30r2yknyfdm29hg7d75dys85p6dz39m6f"}]},{"type":"transfer","attributes":[{"key":"recipient","value":"cosmos1270k40m4rdd60huuf8uhyllql8wh3g6zppcwp5"},{"key":"sender","value":"cosmos1yrxya30r2yknyfdm29hg7d75dys85p6dz39m6f"},{"key":"amount","value":"500token"}]}]}]'
    timestamp: ""
    tx: null
    txhash: F9ACC35F130B49EE6AE7A6CC505BA79D4B0F76398582AA4BE5D2242B0367F2AE
    

Bob 确认自己买到了这个域名：

    cnsd query cns show-domain ag.cns       
    domain:
      isForSale: false
      name: ag.cns
      owner: cosmos1yrxya30r2yknyfdm29hg7d75dys85p6dz39m6f
      price: "500"
      validThru: "12625268"
    

同样不出所料，Alice 进账 500 Token，Bob 花了 300 Token（一开始有 9,000 Token）：

    cnsd query bank balances $(cnsd keys show alice -a)
    balances:
    - amount: "100000000"
      denom: stake
    - amount: "20300"
      denom: token
    pagination:
      next_key: null
      total: "0"
    

    cnsd query bank balances $(cnsd keys show bob -a) 
    balances:
    - amount: "100000000"
      denom: stake
    - amount: "8700"
      denom: token
    pagination:
      next_key: null
      total: "0"
    

* * *

以上便是用 Cosmos SDK 写 Layer 1 区块链的全过程。很多口水代码，实际关键代码并不多。为了提效，大家可以用 Ignite 来生成脚手架。

当然，演示用的例子可能还不是很过瘾，不过之后要扩展的话，无非是组合调用各个自带模块的能力罢了，思路大同小异。

后面我们将深入到智能合约等其他普遍应用的模块中去，了解这些模块的设计思路与应用方法，通过更多案例来一步步完善我们关于 Layer 1 的知识结构。

---

*Originally published on [无环图](https://paragraph.com/@0x257dfbc2836361d04fc4badcc3645a9bc63abdbf/10-21)*
