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

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

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

作为《Tendermint 导读》的续篇,废话不多说,这次我们就来探探,Cosmos SDK 是如何带领大家走进“人人发链”时代的。

本文要点:

  • Cosmos SDK 架构概览

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


Cosmos SDK 架构概览

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

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

回到《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》中有简单介绍。

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

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

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

来源:https://docs.cosmos.network/v0.47/intro/sdk-design
来源: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 上克隆下来就好。

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

  • /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 做的是将namestring转为[]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 的知识结构。

Subscribe to 无环图
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.