# 10 分钟构建自己的区块链|乌托邦周报 #21 **Published by:** [无环图](https://paragraph.com/@0x257dfbc2836361d04fc4badcc3645a9bc63abdbf/) **Published on:** 2023-06-07 **URL:** https://paragraph.com/@0x257dfbc2836361d04fc4badcc3645a9bc63abdbf/10-21 ## Content 从早期的 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 回到《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 整个 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 的知识结构。 ## Publication Information - [无环图](https://paragraph.com/@0x257dfbc2836361d04fc4badcc3645a9bc63abdbf/): Publication homepage - [All Posts](https://paragraph.com/@0x257dfbc2836361d04fc4badcc3645a9bc63abdbf/): More posts from this publication - [RSS Feed](https://api.paragraph.com/blogs/rss/@0x257dfbc2836361d04fc4badcc3645a9bc63abdbf): Subscribe to updates