10 分钟构建自己的区块链|乌托邦周报 #21
从早期的 ICO,到最近的 BRC-20、BRC-721,“人人发币”早已算不上什么新鲜事。
与其琢磨给自己的币种起名,设置流通量和规则,幻想有朝一日币价翻天,倒不如花 10 分钟,发一条完完整整的 Layer 1 区块链来得实在和酷。
作为《Tendermint 导读》的续篇,废话不多说,这次我们就来探探,Cosmos SDK 是如何带领大家走进“人人发链”时代的。
本文要点:
Cosmos SDK 架构概览
手把手写一个类 ENS 区块链
Cosmos SDK 架构概览
Cosmos SDK 是基于 Tendermint(现 CometBFT)共识和网络层构建的区块链模块化开发框架。
回到《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 文档(勇士除外)。虽然文档内容相比几年前已大为进步,但行文依然十分艰涩难懂。事无巨细一一交代,结果就是读者很容易迷失。
要快速入门,其实看文档中的这张图就好了。
整个 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 下。遵循的范式是:
获取 KV 存储对象;
对对象作操作。
以“查”为例,获取 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 的知识结构。