区块链大学区块链/跨链技术研究区块链研习社

Cosmos文档(中文版)-- 3. Cosmos SDK

2018-08-11  本文已影响2人  糙米薏仁汤

由本人翻译, 转载需先说明

所有章节:

  1. 介绍
  2. 开始
  3. Cosmos SDK
  4. Lotion JS
  5. 验证人
  6. 委托人

英文版文档:Cosmos Docs
中文版白皮书:Cosmos白皮书

Cosmos SDK概览

Cosmos-SDK是一个用Golang编写的用以创建Tendermint ABCI应用程序的框架。它被设计成让开发者能轻易在Cosmos网络上创建定制化的,可相互操作的区块链应用程序。

为了达到安全和灵活的目标,SDK广泛使用了object-capability modelprinciple of least privilege

这里有关于object-capabilities的介绍。

语言

尽管框架也能用其他语言大致实现,但目前Cosmos-SDK仅以Golang编写。联系我们了解更多关于赞助以其他语言实现Cosmos SDK的信息。

目录结构

SDK的目录结构如下:

Object-Capability Model

在考虑安全性的时候,从一个特定的威胁模型开始是不错的做法。我们设定的威胁模型如下:

我们假设有一个繁荣的生态,建立在易于编写进区块链应用的Cosmos-SDK模块上,生态中包括了一些有缺陷的或恶意的模块。

Cosmos-SDK被设计成身为一个object capability系统的基础,来定位这个威胁。

object capability系统在结构上的特性支持了代码的模块化设计,确保了代码实现上可靠的封装性。

这些结构上的特性促进了对一个object-capability项目或操作系统的一些安全性进行分析。其中的一些——尤其是信息流特性——能在对象相关性和连通性的级别上被分析,独立与任何知识或对决定对象行为的代码的分析。

作为结果,在包含了未知的及有可能是恶意的代码的对象面前,这些安全性特质能够被建立及维持。

这些结构上的特点源于这两条已存在对象的访问权限的管理规则:

  1. 只有在对象A与对象B相关的情况下,对象A才可以给对象B发送消息。
  2. 如果对象A收到了一条包含关联到对象C的消息,对象A就关联到了对象C。作为这两条规则的结果,只有通过一条已经存在的关联关系链条,一个对象可以关联到另一个对象。简而言之,“只有连通才能产生连通”

查看Object Capability Model的维基百科来获取更多信息

严格来讲,Golang没有彻底实现object capabilities,因为这几个问题:

第一条能简单地通过审计引入和使用一个Dep这样的版本依赖控制系统来发现。第二点和第三点能够靠一些花费被审计到。

也许Go2可以实现对象能力模型

这看起来像什么?

现在揭露对于完成工作来说哪些是必要的

例如,下面的代码段违反了object capabilities原则:

type AppAccount struct {...}
var account := &AppAccount{
    Address: pub.Address(),
    Coins: sdk.Coins{{"ATM", 100}},
}
var sumValue := externalModule.ComputeSumValue(account)

函数ComputeSumValue默示了一个纯函数,意味着接收一个指针值并能对之进行修改。更好的方法声明应该使用一个拷贝。

var sumValue := externalModule.ComputeSumValue(*account)

在Cosmos SDK中,你可以看到basecoin实例文件夹中的应用程序就是基于的这个原则:

// File: cosmos-sdk/examples/basecoin/app/init_handlers.go
package app

import (
    "github.com/cosmos/cosmos-sdk/x/bank"
    "github.com/cosmos/cosmos-sdk/x/sketchy"
)

func (app *BasecoinApp) initRouterHandlers() {

    // All handlers must be added here.
    // The order matters.
    app.router.AddRoute("bank", bank.NewHandler(app.accountMapper))
    app.router.AddRoute("sketchy", sketchy.NewHandler())
}

在这个Basecoin示例中,sketchy handler没有提供一个账户映射,账户映射提供了具有能力的bank handler(连接一笔交易执行时的上下文)。

应用程序结构

SDK具有多个级别的“应用程序”:ABCI应用,BaseApp,BasecoinApp,还有你自己的应用。

ABCI App

基础的ABCI接口允许Tendermint运行带交易区块的应用状态机。

BaseApp

使用一个持久化的多重存储和一个处理交易的路由处理器来实现一个ABCI应用。尽管对状态机的定义尽可能的少,也要在存储和可拓展状态机之间提供一个安全的接口(忠于ABCI)。

BaseApp需要存储通过能力关键词来被挂载上——处理器只能访问其关键词对应的存储。BaseApp确保所有的存储被正常加载,缓存和提交保存。一个已挂载的存储被认为是“主体”——它持有最新的区块头,从中我们可以找到并载入的最新的状态。

BaseApp区分了两种处理器类型——AnteHandlerMsgHandler。前者是一个全局性的有效性检查(检查nonce,签名和充足的余额用于支付交易手续费,还有那些适用于所有模块中所有交易的东西),后者是一个完整状态的转变函数。在CheckTx期间,状态转变函数只适用于checkTxState,应该在任何高消耗状态转变执行之前就返回(对所有开发者都适用)。这同样需要去返回估算的燃料消耗。

在DeliverTx期间,状态转变函数也适用于区块链状态,交易需要去被完全执行。

BaseApp负责管理在处理器间传递的上下文环境——这样可以获取区块的头部信息,还有为CheckTx和DeliverTx提供正确的存储。BaseApp对序列化格式来说是完全不可知的。

Basecoin

Basecoin是Cosmos SDK栈上的首个完整应用程序。完整的应用程序需要对实现了功能性处理器的SDK的核心模块进行拓展。

SDK原生的拓展,对创建Cosmos分区是有用的,在x模块下可以看到。Basecoin使用了x/authx/bank拓展来实现了一个BaseApp状态机,定义了怎样对交易签署者进行身份验证和coin是怎样转账的。这还要使用到x/ibc,及一个简单的抵押功能的拓展。

Basecoin和原生的x拓展对所有的序列化需求使用了go-amino,包括交易和账户

你的Cosmos应用

你的Cosmos是Basecoin的一个分支——复制了examples/basecoin目录并按你的需求去修改。你可能想:

Cosmos Hub使用Basecoin,加入了更多的存储和拓展去处理额外的交易类型和逻辑,比如先进的抵押逻辑和治理过程。

Ethermint

Ethermint是一个对BaseApp新的实现,不依赖于Basecoin。它有自己的基于go-ethereumethermint/x,替代了cosmos-sdk/x/

Ethermint为账户体系使用了一个Patricia存储,为IBC使用了一个IAVL存储。Ethermint有x/ante, 这与Basecoin的非常相似,但是Ethermint使用了RLP去替代了go-amino。用x/eth替换了x/bank,定义了独立的Ethereum交易类型和所有语义的Ethereum状态机。

有了x/eth,发送到特定地址的交易能够以独特的方式被处理,比如去处理IBC和股权抵押。

介绍

欢迎来到Cosmos-SDK Core的文档

在这里你将会学习如何使用Cosmos-SDK去创建Basecoin这样一个完整的权益证明的数字货币系统。

我们继续一系列课程来实现一个逐渐先进和完整的Basecoin应用程序,其中的每一个实现都展示了SDK的某个新组件:

基础

我们通过创建App1来介绍SDK的基本组件,App1是一个简单的银行,用户有一个账户和账户地址,用户之间可以相互发送coin。这个银行没有身份验证功能,且只使用JSON去序列化。

完整的代码可以在app1.go中看到。

Message

Message是应用状态机的基本输入。它们定义了交易的内容,能够包含任意的信息。开发者可以通过实现Msg接口来创建message:

type Msg interface {
    // Return the message type.
    // Must be alphanumeric or empty.
    // Must correspond to name of message handler (XXX).
    Type() string

    // ValidateBasic does a simple validation check that
    // doesn't require access to any other information.
    ValidateBasic() error

    // Get the canonical byte representation of the Msg.
    // This is what is signed.
    GetSignBytes() []byte

    // Signers returns the addrs of signers that must sign.
    // CONTRACT: All signatures must be present to be valid.
    // CONTRACT: Returns addrs in some deterministic order.
    GetSigners() []AccAddress
}

Msg接口允许message去定义基础的合法性验证,还有哪些需要被签名以及谁需要对这些签名。

例如app1.go中,简易的token发送的message类型:

// MsgSend to send coins from Input to Output
type MsgSend struct {
    From   sdk.AccAddress `json:"from"`
    To     sdk.AccAddress `json:"to"`
    Amount sdk.Coins   `json:"amount"`
}

// Implements Msg.
func (msg MsgSend) Type() string { return "bank" }

指定了message应该转化成JSON格式,以及由发送者签名:

// Implements Msg. JSON encode the message.
func (msg MsgSend) GetSignBytes() []byte {
    bz, err := json.Marshal(msg)
    if err != nil {
        panic(err)
    }
    return bz
}

// Implements Msg. Return the signer.
func (msg MsgSend) GetSigners() []sdk.AccAddress {
    return []sdk.AccAddress{msg.From}
}

注意在以字符串的形式显示时或者以JSON形式表达时,SDK中的地址是任意Bech32编码格式的字符数组。典型的就是,地址是公钥的哈希,所以在一笔交易需要签名时,我们可以使用它们作为独一无二的身份识别。

基本的有效性验证确保指定了发送方地址和接收方地址且发送数量是有效的:

// Implements Msg. Ensure the addresses are good and the
// amount is positive.
func (msg MsgSend) ValidateBasic() sdk.Error {
    if len(msg.From) == 0 {
        return sdk.ErrInvalidAddress("From address is empty")
    }
    if len(msg.To) == 0 {
        return sdk.ErrInvalidAddress("To address is empty")
    }
    if !msg.Amount.IsPositive() {
        return sdk.ErrInvalidCoins("Amount is not positive")
    }
    return nil
}

注意ValidateBasic方法会被SDK自动调用!

KVStore

KVStore是一个SDK应用程序的基本的持久层:

type KVStore interface {
    Store

    // Get returns nil iff key doesn't exist. Panics on nil key.
    Get(key []byte) []byte

    // Has checks if a key exists. Panics on nil key.
    Has(key []byte) bool

    // Set sets the key. Panics on nil key.
    Set(key, value []byte)

    // Delete deletes the key. Panics on nil key.
    Delete(key []byte)

    // Iterator over a domain of keys in ascending order. End is exclusive.
    // Start must be less than end, or the Iterator is invalid.
    // CONTRACT: No writes may happen within a domain while an iterator exists over it.
    Iterator(start, end []byte) Iterator

    // Iterator over a domain of keys in descending order. End is exclusive.
    // Start must be greater than end, or the Iterator is invalid.
    // CONTRACT: No writes may happen within a domain while an iterator exists over it.
    ReverseIterator(start, end []byte) Iterator

 }

注意值为nil的键值将会引发严重的错误!!!

IVAL存储是KVStore目前最基本的实现。今后,我们计划去支持其他的默克尔KVstore,就像Ethereum的基数树。

如同我们看到的那样,应用有许多不同的KVStore,每个都有一个不同的名称和有不同的用途。对一个存储的访问权限由object-capability keys来协调,其在应用程序启动期间必须被授权到一个handler中。

Handler

现在我们有了一个消息类型和一个存储接口,我们可以使用一个handler去定义我们的状态变更函数:

// Handler defines the core of the state transition function of an application.
type Handler func(ctx Context, msg Msg) Result

通过这个消息,处理器获得了环境消息(一个Context),并返回一个Result。所有处理消息的必要信息都要能在上下文中获得。

哪里能获取所有的KVStore呢?在一条消息处理器中,对KVStore的访问全向被上下文用object-capability keys所限制。在处理消息期间,只有那些被显示赋予访问权限的处理器才能访问存储。

Context

SDK使用一个Context在函数之间传递通用的信息。最重要的是,Context限制了对KVStores的基于object-capability keys的访问。只有那些被明确赋予访问权限的处理器才能够去访问相应的存储。

例如,FooHandler只能载入由其key值赋予的存储:

// newFooHandler returns a Handler that can access a single store.
func newFooHandler(key sdk.StoreKey) sdk.Handler {
    return func(ctx sdk.Context, msg sdk.Msg) sdk.Result {
        store := ctx.KVStore(key)
        // ...
    }
}

Context仿造了Golang的context.Context,在网络中间件和路由应用中作为一个通过处理器函数简单传播请求内容的工具,context.Context已经变得极其普遍了。许多SDK对象的方法接受一个上下文作为第一个参数。

上下文中同样包含了区块头信息,包含了从区块链中得到的最新时间戳和关于最新区块的其他信息。

查看Context API文档获取更多细节。

Result

Handler获取一个Context和Msg并返回一个Result。Result由相应的ABCI result驱动。它包含了返回值,错误信息,日志和交易的元数据:

// Result is the union of ResponseDeliverTx and ResponseCheckTx.
type Result struct {

    // Code is the response code, is stored back on the chain.
    Code ABCICodeType

    // Data is any data returned from the app.
    Data []byte

    // Log is just debug information. NOTE: nondeterministic.
    Log string

    // GasWanted is the maximum units of work we allow this tx to perform.
    GasWanted int64

    // GasUsed is the amount of gas actually consumed. NOTE: unimplemented
    GasUsed int64

    // Tx fee amount and denom.
    FeeAmount int64
    FeeDenom  string

    // Tags are used for transaction indexing and pubsub.
    Tags Tags
}

我们将在之后的教程中讨论这些字段的更多细节。现在,注意Code是一个0值的话被认为是成功,其他值被认作失败。Tags可以包含关于交易的元数据,将让我们能轻易地查找那些属于特定账户或行为的交易。

Handler

在App1中定义我们的handler:

// Handle MsgSend.
// NOTE: msg.From, msg.To, and msg.Amount were already validated
// in ValidateBasic().
func handleMsgSend(ctx sdk.Context, key *sdk.KVStoreKey, msg MsgSend) sdk.Result {
    // Load the store.
    store := ctx.KVStore(key)

    // Debit from the sender.
    if res := handleFrom(store, msg.From, msg.Amount); !res.IsOK() {
        return res
    }

    // Credit the receiver.
    if res := handleTo(store, msg.To, msg.Amount); !res.IsOK() {
        return res
    }

    // Return a success (Code 0).
    // Add list of key-value pair descriptors ("tags").
    return sdk.Result{
        Tags: msg.Tags(),
    }
}

我们仅有一个独立的消息类型,所以定义了handleMsgSend这个函数。

注意这个处理器对由keyAcc指定的存储,有不受限制的访问权限,所以必须要定义存储哪些内容及如何去做编码。之后,我们将引入高级的抽象使处理器被限制只能去做我们能够做的事情。第一个例子,我们使用一个JSON格式的简易账户:

type appAccount struct {
    Coins sdk.Coins `json:"coins"`
}

Coins是SDK为多资产账户提供的类型。这里对于一个独立的的coin类型,我们可以只使用一个整数,但最好还是去了解下Coins

现在我们准备好了去处理MsgSend的两个部分:

func handleFrom(store sdk.KVStore, from sdk.AccAddress, amt sdk.Coins) sdk.Result {
    // Get sender account from the store.
    accBytes := store.Get(from)
    if accBytes == nil {
        // Account was not added to store. Return the result of the error.
        return sdk.NewError(2, 101, "Account not added to store").Result()
    }

    // Unmarshal the JSON account bytes.
    var acc appAccount
    err := json.Unmarshal(accBytes, &acc)
    if err != nil {
        // InternalError
        return sdk.ErrInternal("Error when deserializing account").Result()
    }

    // Deduct msg amount from sender account.
    senderCoins := acc.Coins.Minus(amt)

    // If any coin has negative amount, return insufficient coins error.
    if !senderCoins.IsNotNegative() {
        return sdk.ErrInsufficientCoins("Insufficient coins in account").Result()
    }

    // Set acc coins to new amount.
    acc.Coins = senderCoins

    // Encode sender account.
    accBytes, err = json.Marshal(acc)
    if err != nil {
        return sdk.ErrInternal("Account encoding error").Result()
    }

    // Update store with updated sender account
    store.Set(from, accBytes)
    return sdk.Result{}
}

func handleTo(store sdk.KVStore, to sdk.AccAddress, amt sdk.Coins) sdk.Result {
    // Add msg amount to receiver account
    accBytes := store.Get(to)
    var acc appAccount
    if accBytes == nil {
        // Receiver account does not already exist, create a new one.
        acc = appAccount{}
    } else {
        // Receiver account already exists. Retrieve and decode it.
        err := json.Unmarshal(accBytes, &acc)
        if err != nil {
            return sdk.ErrInternal("Account decoding error").Result()
        }
    }

    // Add amount to receiver's old coins
    receiverCoins := acc.Coins.Plus(amt)

    // Update receiver account
    acc.Coins = receiverCoins

    // Encode receiver account
    accBytes, err := json.Marshal(acc)
    if err != nil {
        return sdk.ErrInternal("Account encoding error").Result()
    }

    // Update store with updated receiver account
    store.Set(to, accBytes)
    return sdk.Result{}
}

处理器继续向前。我们先使用授权的capability key,从上下文中加载KVStore。然后我们生成两个状态交易:一个针对发送者,一个针对接收者。每一个都把存储中的账户字符中由JSON格式解码,来改变Coins, 再以JSON格式编码后放回到存储中。

Tx

在把这些都整合之前,最后一步就是Tx。虽然Msg包含应用程序中特定功能的内容,用户提供的输入实际上是一个序列化的Tx。应用程序可以有许多对Msg接口的实现,但是他们只能够有一个Tx的独立实现:

// Transactions wrap messages.
type Tx interface {
    // Gets the Msgs.
    GetMsgs() []Msg
}

Tx仅包含了一个[]Msg,可以含有额外的身份验证数据,比如签名和账户的nonce。应用程序必须指定他们的Tx要如何解码,因为往应用程序里输入是首要的事情。在引入StdTx的时候,我们再讨论Tx

在第一个应用程序中,我们完全没有任何的身份验证。这在访问权被控制的私有网络中可能是行得通的,就像client-side TLS certificates。但通常情况下,我们把身份验证权限加到我们的状态机中。我们会在下一个应用中使用Tx来实现。现在,Tx只嵌入了MsgSend和使用JSON:

// Simple tx to wrap the Msg.
type app1Tx struct {
    MsgSend
}

// This tx only has one Msg.
func (tx app1Tx) GetMsgs() []sdk.Msg {
    return []sdk.Msg{tx.MsgSend}
}

// JSON decode MsgSend.
func txDecoder(txBytes []byte) (sdk.Tx, sdk.Error) {
    var tx app1Tx
    err := json.Unmarshal(txBytes, &tx)
    if err != nil {
        return nil, sdk.ErrTxDecode(err.Error())
    }
    return tx, nil
}

BaseApp

最后,我们使用BaseApp来把这些整合到一块儿。

BaseApp是一个基于Tendermint ABCI的抽象,Tendermint ABCI通过在底层处理通用的用途来简化应用程序的开发。它作为SDK应用中两个关键组件存储和消息处理器之间的中介。BaseApp实现了abci.Application接口。查看BaseApp API文档获取更多细节。

这里是App1的完整设置:

func NewApp1(logger log.Logger, db dbm.DB) *bapp.BaseApp {
    cdc := wire.NewCodec()

    // Create the base application object.
    app := bapp.NewBaseApp(app1Name, cdc, logger, db)

    // Create a capability key for accessing the account store.
    keyAccount := sdk.NewKVStoreKey("acc")

    // Determine how transactions are decoded.
    app.SetTxDecoder(txDecoder)

    // Register message routes.
    // Note the handler receives the keyAccount and thus
    // gets access to the account store.
    app.Router().
        AddRoute("bank", NewApp1Handler(keyAccount))

    // Mount stores and load the latest state.
    app.MountStoresIAVL(keyAccount)
    err := app.LoadLatestVersion(keyAccount)
    if err != nil {
        cmn.Exit(err.Error())
    }
    return app
}

每个应用都有一个定义了应用设置的方法。它一般包含在一个app.go文件里。在之后的教程中,我们将会讨论如何把应用连接到CLI,REST API,日志,和文件系统。此刻,注意这里我们为消息类型注册了控制器,授权它们去访问存储。

这里,我们仅有一个独立的Msg类型,bank,一个针对账户的独立存储,和一个独立的处理器。处理器被授予对相应存储的访问权限。在之后的应用中,我们将有多个存储和处理器,可不是所有的处理器都能访问每一个存储。

在设置好交易解码器和消息处理的路由后,最后一步是去挂载这些存储并载入最新的版本。因为我们只有一个存储,我们就只挂载一个。

执行

现在我们已经完成了应用的核心逻辑!从这里开始,我们可以用Go编写测试模块来初始化账户的存储,还有通过调用app.DeliverTx方法来执行交易了。

在一个实际的设置里,应用会以一个Tendermint共识引擎之上的ABCI应用程序的形式来运行。这将由一个Genesis文件来初始化,由底层的Tendermint共识提交的交易区块来驱动。一会儿我们将会讨论有关ABCI和它们是如何工作的更多细节,也可以查看相关说明。我们还可以看到怎样把我们的应用连接到一套完整的组件上去运行以及如何使用一个线上的区块链应用。

现在,我们注意下方,当接收到一笔交易时(通过app.DeliverTx),事件发生的顺序:

总结

现在我们已经有了一个简单应用的完整实现!

在接下来的章节,我们将会添加另一个Msg类型和另一个存储。一旦有多个消息类型后,我们需要用一个更好的方式去解码交易,所以我们需要解码到Msg接口。这里我们要引入Amino——能用做解码接口类型的高级编码协议。

Transactions

在之前的应用中,我们使用一个消息类型来发送coin,用一个存储来储存账户信息,从而创建了一个简单的bank。这里我们通过引入以下几点来拓展App1,创建App2

我们会继续介绍用Amino去编码和解码交易,还有使用AnteHandler去处理它们。

完整的代码可以在app2.go看到。

Message

让我们介绍一个新的交易类型来发行coin:

// MsgIssue to allow a registered issuer
// to issue new coins.
type MsgIssue struct {
    Issuer   sdk.AccAddress
    Receiver sdk.AccAddress
    Coin     sdk.Coin
}

// Implements Msg.
func (msg MsgIssue) Type() string { return "issue" }

注意Type()方法返回"issue", 所以这个消息是一个不同的类型,将会由一个不同于"MsgSend"的处理器来执行。另一个MsgIssue方法与MsgSend类似。

Handler

我们需要一个新的处理器去支持新的消息类型。它依据发行者存储里的信息,检查MsgIssue的发送者是否是指定coin类型的发行者,:

// Handle MsgIssue
func handleMsgIssue(keyIssue *sdk.KVStoreKey, keyAcc *sdk.KVStoreKey) sdk.Handler {
    return func(ctx sdk.Context, msg sdk.Msg) sdk.Result {
        issueMsg, ok := msg.(MsgIssue)
        if !ok {
            return sdk.NewError(2, 1, "MsgIssue is malformed").Result()
        }

        // Retrieve stores
        issueStore := ctx.KVStore(keyIssue)
        accStore := ctx.KVStore(keyAcc)

        // Handle updating coin info
        if res := handleIssuer(issueStore, issueMsg.Issuer, issueMsg.Coin); !res.IsOK() {
            return res
        }

        // Issue coins to receiver using previously defined handleTo function
        if res := handleTo(accStore, issueMsg.Receiver, []sdk.Coin{issueMsg.Coin}); !res.IsOK() {
            return res
        }

        return sdk.Result{
            // Return result with Issue msg tags
            Tags: issueMsg.Tags(),
        }
    }
}

func handleIssuer(store sdk.KVStore, issuer sdk.AccAddress, coin sdk.Coin) sdk.Result {
    // the issuer address is stored directly under the coin denomination
    denom := []byte(coin.Denom)
    infoBytes := store.Get(denom)
    if infoBytes == nil {
        return sdk.ErrInvalidCoins(fmt.Sprintf("Unknown coin type %s", coin.Denom)).Result()
    }

    var coinInfo coinInfo
    err := json.Unmarshal(infoBytes, &coinInfo)
    if err != nil {
        return sdk.ErrInternal("Error when deserializing coinInfo").Result()
    }

    // Msg Issuer is not authorized to issue these coins
    if !bytes.Equal(coinInfo.Issuer, issuer) {
        return sdk.ErrUnauthorized(fmt.Sprintf("Msg Issuer cannot issue tokens: %s", coin.Denom)).Result()
    }

    return sdk.Result{}
}

// coinInfo stores meta data about a coin
type coinInfo struct {
    Issuer sdk.AccAddress `json:"issuer"`
}

注意我们已经引入了coinInfo类型去储存每种coin的发行者地址。我们将其以JSON格式序列化,直接保存在Issuer字段下。当然这里我们可以添加更多的字段和逻辑,比如coin当前的总供应量,还可以强制一个最大的供应量。给读者留下个练习吧。

Amino

现在我们对Msg有的两种实现了,在处理之前我们并不会知道一个序列化的Tx包含的是哪一种类型。理想情况下,在我们的Tx实现里使用Msg接口,但是JSON格式的解码器不能够把数据解码成接口类型。事实上,Go中没有解码成接口的标准方式。这就是我们创建Amino的一个主要原因。

尽管SDK的开发者可以按他们喜欢的方式去对交易和状态对象进行编码,但Amino还是推荐的格式。Amino的目标是去提升到最新版本的Protocol Buffers之上——proto3。为了达到这个目的,Amino将兼容proto3中除去oneof关键词后的子集。

尽管oneof提供了union类型,Amino要去提供接口。主要的差异之处在于有union类型的话,我们不得不事先获知所有的类型。但是任何人都可以随时按他们的意愿去实现一个接口。

为了实现接口类型,Amino允许任何接口的具体实现去注册一个全局的独一无二的名称,只要在类型被序列化的时候。这允许Amino可以无缝地反序列化出接口类型!

Amino在SDK中对消息的主要用途是实现了Msg接口。通过每次注册消息时都使用一个独特的名称,它们都获得了一个独一无二的Amino前缀,这使得在交易中它们能被轻易区分。

Amino也可以被用作接口的持久化存储。

为了使用Amino,简单地创建一个编码解码器,然后注册类型:

func NewCodec() *wire.Codec {
    cdc := wire.NewCodec()
    cdc.RegisterInterface((*sdk.Msg)(nil), nil)
    cdc.RegisterConcrete(MsgSend{}, "example/MsgSend", nil)
    cdc.RegisterConcrete(MsgIssue{}, "example/MsgIssue", nil)
    return cdc
}

Amino支持对binary和JSON格式的编码和解码。查看codec API文档获得更多细节。

Tx

现在我们正使用Amino,我们将Msg接口直接嵌入到Tx。我们还可以添加一个公钥和一个签名用于身份验证。

// Simple tx to wrap the Msg.
type app2Tx struct {
    sdk.Msg
    
    PubKey    crypto.PubKey
    Signature crypto.Signature
}

// This tx only has one Msg.
func (tx app2Tx) GetMsgs() []sdk.Msg {
    return []sdk.Msg{tx.Msg}
}

因为我们只使用了Amino编码解码器而已,那就不需要再去定制TxDecoder函数了。

AnteHandler

现在我们有一个不止包含Msg的Tx实现,我们需要指明其他的信息要如何被验证和处理。这就是AnteHandler要担任的角色。因为AnteHandler在一个Handler之前运行,所以ante在这里表示"预先"。尽管一个应用可以有许多的处理器对应每一组消息,但是应用只能有单独的AnteHandler对应Tx单独的实现。

AnteHandler与Handler类似:

type AnteHandler func(ctx Context, tx Tx) (newCtx Context, result Result, abort bool)

像Handler那样,AnteHandler使用了一个上下文,其限制了对存储按capability keys授予的访问权限。它使用Tx来替代了Msg

像Handler那样,AnteHandler返回了一个Result类型,但是AnteHandler还返回一个新的Context和一个abort bool

关于App2,我们简单去校验公钥是否匹配地址,还有签名是否对应公钥:

// Simple anteHandler that ensures msg signers have signed.
// Provides no replay protection.
func antehandler(ctx sdk.Context, tx sdk.Tx) (_ sdk.Context, _ sdk.Result, abort bool) {
    appTx, ok := tx.(app2Tx)
    if !ok {
        // set abort boolean to true so that we don't continue to process failed tx
        return ctx, sdk.ErrTxDecode("Tx must be of format app2Tx").Result(), true
    }

    // expect only one msg in app2Tx
    msg := tx.GetMsgs()[0]

    signerAddrs := msg.GetSigners()

    if len(signerAddrs) != len(appTx.GetSignatures()) {
        return ctx, sdk.ErrUnauthorized("Number of signatures do not match required amount").Result(), true
    }

    signBytes := msg.GetSignBytes()
    for i, addr := range signerAddrs {
        sig := appTx.GetSignatures()[i]

        // check that submitted pubkey belongs to required address
        if !bytes.Equal(sig.PubKey.Address(), addr) {
            return ctx, sdk.ErrUnauthorized("Provided Pubkey does not match required address").Result(), true
        }

        // check that signature is over expected signBytes
        if !sig.PubKey.VerifyBytes(signBytes, sig.Signature) {
            return ctx, sdk.ErrUnauthorized("Signature verification failed").Result(), true
        }
    }

    // authentication passed, app to continue processing by sending msg to handler
    return ctx, sdk.Result{}, false
}

App2

让我们把这些全合并到一块儿,就得到了App2:

func NewApp2(logger log.Logger, db dbm.DB) *bapp.BaseApp {

    cdc := NewCodec()

    // Create the base application object.
    app := bapp.NewBaseApp(app2Name, cdc, logger, db)

    // Create a key for accessing the account store.
    keyAccount := sdk.NewKVStoreKey("acc")
    // Create a key for accessing the issue store.
    keyIssue := sdk.NewKVStoreKey("issue")

    // set antehandler function
    app.SetAnteHandler(antehandler)

    // Register message routes.
    // Note the handler gets access to the account store.
    app.Router().
        AddRoute("send", handleMsgSend(keyAccount)).
        AddRoute("issue", handleMsgIssue(keyAccount, keyIssue))

    // Mount stores and load the latest state.
    app.MountStoresIAVL(keyAccount, keyIssue)
    err := app.LoadLatestVersion(keyAccount)
    if err != nil {
        cmn.Exit(err.Error())
    }
    return app
}

相比App1的主要不同之处是,我们使用了一个次要的capability key去对应一个次要存储,就只能传递给一个次要的处理器——handleMsgIssue。首要的handleMsgSend没有访问这个次要存储的权限,既不能读也不能写,从而确保了业务上的强分离性。

注意这里我们也不需要使用SetTxDecoder——现在我们正在使用Amino,我们简单地创建一个编码解码器,在编码解码器上注册我们的类型,然后把这个编码解码器传递到NewBaseApp。SDK替我们做了接下来的事情!

总结

通过添加一个新的message类型来发行coin,还有对签名的校验,我们已经拓展了第一个应用。我们已经学会怎样使用Amino去解码出接口类型,使我们能够支持多种Msg类型,还学会了使用AnteHandler去验证交易的合法性。

不幸的是,我们的应用程序依然是不安全的,因为任意合法的交易都能被重放多次从而榨干某人的账户!此外,验证签名和预防重放攻击不是开发者需要考虑的事情。

在下一个章节,我们引入内置的SDK模块authbank,它们分别提供了交易身份验证和coin转账所需一切的安全实现。

模块

在之前的应用中,我们引入了一个新的Msg类型,并使用Amino去编码交易。还在Tx里引入了额外数据,并使用一个简单的AnteHandler来验证它。

这里,在App3中,我们将会引入x/auth,x/bank这两个内置的SDK模块,来代替Msg,Tx,HandlerAnteHandler,去实现到目前为止所做的事。

x/auth模块实现了TxAnteHandler——它具备验证交易身份所需的一切。还包括一个新的Account类型,去简化存储中账户相关的工作。

x/bank模块实现了MsgHandler——具备了账户之间转账coin所需的一切。
这里,我们将会从x/authx/bank中引入重要的类型,使用它们去创建App3。完整版的代码可以在这章的末尾的app3.go中找到。

查看x/authx/bank的API文档来获得更多细节。

账户

x/auth模块定义了与Ethereum很类似的账户模型。在这个模型中,一个账户包含:

注意AccountNumber是一个独一无二的数字,在账户创建时被分配。Sequence在该账户每发送一笔交易时便会增加1。

Account

下面的Account就是一个带有getters和setters的账户模型:

// Account is a standard account using a sequence number for replay protection
// and a pubkey for authentication.
type Account interface {
    GetAddress() sdk.AccAddress
    SetAddress(sdk.AccAddress) error // errors if already set.

    GetPubKey() crypto.PubKey // can return nil.
    SetPubKey(crypto.PubKey) error

    GetAccountNumber() int64
    SetAccountNumber(int64) error

    GetSequence() int64
    SetSequence(int64) error

    GetCoins() sdk.Coins
    SetCoins(sdk.Coins) error
}

注意这是一个低级别的接口——里面的任意字段都可以被覆盖。我们很快能看到,可以通过Keeper范式来限制访问权限。

BaseAccount

BaseAccountAccount的默认实现:

// BaseAccount - base account structure.
// Extend this by embedding this in your AppAccount.
// See the examples/basecoin/types/account.go for an example.
type BaseAccount struct {
    Address       sdk.AccAddress `json:"address"`
    Coins         sdk.Coins      `json:"coins"`
    PubKey        crypto.PubKey  `json:"public_key"`
    AccountNumber int64          `json:"account_number"`
    Sequence      int64          `json:"sequence"`
}

它简单地包含这些字段。

AccountMapper

先前的应用中使用了appAccount,通过直接在KVStore上执行操作,我们从存储中序列化/反序列化账户信息。但是没有访问权限限制的接口,并不是我们真正想要在应用程序里使用的接口。在SDK中,我们使用Mapper这样一个基于KVStore的抽象,来把特定的数据类型序列化到底层存储中,或是从底层存储中反序列化。

x/auth模块提供了一个AccountMapper来让我们从存储中获取或者设置Account类型。注意使用Account接口的好处是——开发者可以拓展BaseAccount实现自己的接口来存储额外的数据,而不需要从存储中另行查找。

创建一个AccountMapper是很简单的——我们只需要去指定一个编码解码器,一个capability key,和一个编码对象的原型。

accountMapper := auth.NewAccountMapper(cdc, keyAccount, auth.ProtoBaseAccount)

然后我们可以获取,修改和设置账户信息。例如,我们可以把一个账户里的coin翻倍:

acc := accountMapper.GetAccount(ctx, addr)
acc.SetCoins(acc.Coins.Plus(acc.Coins))
accountMapper.SetAccount(ctx, addr)

注意AccountMapper使用了一个Context作为第一个参数,将要使用创建时被授权的capability key来载入KVStore。

要记得在改变账户信息之后,你必须要显示地调用SetAccount来把这些改变持久化。

查看AccountMapper API文档来获取更多信息。

StdTx

现在我们有了一个账户信息的原生模型,是时候引入原生的Tx类型auth.StdTx了:

// StdTx is a standard way to wrap a Msg with Fee and Signatures.
// NOTE: the first signature is the FeePayer (Signatures must not be nil).
type StdTx struct {
    Msgs       []sdk.Msg      `json:"msg"`
    Fee        StdFee         `json:"fee"`
    Signatures []StdSignature `json:"signatures"`
    Memo       string         `json:"memo"`
}

这是SDK中交易的标准形式。除了Msg,它还包括了:

关于怎样去验证这些组件的细节可以在下面的auth.AnteHandler中看到。

StdSignature是标准形式的签名:

// StdSignature wraps the Signature and includes counters for replay protection.
// It also includes an optional public key, which must be provided at least in
// the first transaction made by the account.
type StdSignature struct {
    crypto.PubKey    `json:"pub_key"` // optional
    crypto.Signature `json:"signature"`
    AccountNumber    int64 `json:"account_number"`
    Sequence         int64 `json:"sequence"`
}

签名包含了AccountNumberSequence。在生成交易时,Sequence必须要和对应的账户相匹配,且每一笔交易nonce都要增加1.这是为了防止相同的交易被发送多次,这样解决了App2中遗留的安全问题。

AccountNumber同样也是为了重放保护——它允许在账户被使用完毕后删除账户。如果一个账户在它被删除之后收到了一笔coin,这个账户会被重新创建,同时Sequence会被重置为0,但是AccountNumber会是一个新的。如果没有AccountNumber的话,如果某个账户是用之前被删除的账户重新生成而来的,那么其最新sequence的交易是可以被重放的!

最后,交易费用的标准形式是StdFee:

// StdFee includes the amount of coins paid in fees and the maximum
// gas to be used by the transaction. The ratio yields an effective "gasprice",
// which must be above some miminum to be accepted into the mempool.
type StdFee struct {
    Amount sdk.Coins `json:"amount"`
    Gas    int64     `json:"gas"`
}

签名

StdTx支持多个消息和多个签名者。要签署某笔交易的话,每一个签名者必须要收集下面的信息:

然后使用auth.StdSignBytes方法去给计算得来交易字符做签名:

bytesToSign := StdSignBytes(chainID, accNum, accSequence, fee, msgs, memo)

注意这些字节对每一个签名者都是不一样的,因为交易字节依赖于特定签名者的AccountNumber, Sequence和可选的备注信息。为了在签名前能方便检查,字节实际上只是由所有相关信息编码而来的JSON。

AnteHandler

如同我们在App2中看到的,在我们处理任何交易内部的消息之前,可以使用一个AnteHandler去对交易进行身份验证。尽管先前我们实现了一个简单的AnteHandlerx/auth模块提供了更加先进的一个实现,可以使用AccountMapper去处理StdTx:

app.SetAnteHandler(auth.NewAnteHandler(accountMapper, feeKeeper))

AnteHandler由x/auth模块提供,强制了一些以下规则:

注意校验签名需要检查每一个签名者的正确的AccountNumber和Sequence,因为StdSignBytes中这些信息是必需的。

如果上述的条件没有全部满足,AnteHandler会返回一个错误。

如果上面的证明都通过后, AnteHandler可以对state做出如下的改变:

再次调用Sequence会增加来防止“重放攻击”——一条相同的message可以一次次被执行。

签名验证需要PubKey,但只是StdSignature需要用到。从这一点考虑,账户里要存有公钥。

手续费由msg.GetSigners()针对首个Msg返回的第一个地址支付,由FeePayer(tx Tx) sdk.AccAddress方法提供。

CoinKeeper

现在我们可以看到auth.AccountMapper和如何使用它创建一个完整的AnteHandler, 现在是时候去看看怎样给账户相关的操作创建更高级的抽象了。

早前,我们说过Mappers是基于KVStore的抽象,用来从底层的存储中序列化和反序列化数据类型。我们可以创建另一个基于Mappers的抽象,我们叫作Keepers,它只会从由Mapper存储的底层的类型中暴露有的功能限制的出来。

例如,x/bank模块为SDK定义了MsgSendMsgIssue的权威版本,以及一个去处理它们的Handler。尽管如此,比起直接往处理器里传递一个KVStore甚至是一个AccountMapper,我们选择引入bank.Keeper,只能用于往账户里转入转出coin。这允许我们去做决定,而先前bank模块的Handler只能去改变账户里的coin数量——而不能去增加sequence数,去改变PubKeys等。

一个bank.Keeper由一个AccountMapper简单实例化而来:

coinKeeper = bank.NewKeeper(accountMapper)

我们可以搭配着一个处理器来使用它,而不是直接用AccountMapper。比如,往账户里添加coin:

// Finds account with addr in AccountMapper.
// Adds coins to account's coin array.
// Sets updated account in AccountMapper
app.coinKeeper.AddCoins(ctx, addr, coins)

查看bank.Keeper API文档来了解全部的方法。

注意我们可以通过限制方法集合的方式来重新定义bank.Keeper。比如,当bank.SendKeeper只是执行从输入账户向输出账户转账coin时,bank.ViewKeeper是一个只读的版本。

我们在SDK中广泛地使用Keeper这个范式,来作为定义每个模块有权进入哪些功能的方式,我们尽量去遵循最小权限原则。比起提供充分的对KVStoreAccountMapper的访问权限,我们限制了对那些做非常具体事情的方法的访问权。

App3

有了auth.AccountMapperbank.Keeper,现在我们准备创建App3x/authx/bank模块做了所有的累活:

func NewApp3(logger log.Logger, db dbm.DB) *bapp.BaseApp {

    // Create the codec with registered Msg types
    cdc := NewCodec()

    // Create the base application object.
    app := bapp.NewBaseApp(app3Name, cdc, logger, db)

    // Create a key for accessing the account store.
    keyAccount := sdk.NewKVStoreKey("acc")
    keyFees := sdk.NewKVStoreKey("fee")  // TODO

    // Set various mappers/keepers to interact easily with underlying stores
    accountMapper := auth.NewAccountMapper(cdc, keyAccount, auth.ProtoBaseAccount)
    coinKeeper := bank.NewKeeper(accountMapper)
    feeKeeper := auth.NewFeeCollectionKeeper(cdc, keyFees)

    app.SetAnteHandler(auth.NewAnteHandler(accountMapper, feeKeeper))

    // Register message routes.
    // Note the handler gets access to
    app.Router().
        AddRoute("send", bank.NewHandler(coinKeeper))

    // Mount stores and load the latest state.
    app.MountStoresIAVL(keyAccount, keyFees)
    err := app.LoadLatestVersion(keyAccount)
    if err != nil {
        cmn.Exit(err.Error())
    }
    return app
}

注意我们使用了只能处理bank.MsgSend和接受bank.Keeperbank.NewHandler。查看x/bank API文档来获取更多细节。

总结

装备了处理身份验证和coin转账的原生模块,再加上mappers和keepers范式的支持,还有受到鼓励去去创建安全的状态机,我们发现我们有了一个充分发展的,检查到位的,多资产的数字货币——Cosmos-SDK跳动的心。

ABCI

应用区块链接口,即ABCI,是在Cosmos-SDK和Tendermint之间的一次强力的边界划定。它将应用程序里逻辑状态转变机器,与在多台物理机器之间安全复制这两块分隔开来。

通过在应用程序和共识之间提供一个清晰的,语言未知的界限,ABCI提供了巨大的开发者灵活性并支持多种语言。也就是说,它仍然是一个相当底层的协议,需要在底层组件之上创建抽象的框架。Cosmos-SDK就是这样一个框架。

尽管我们已经看到了DeliverTx这样的ABCI应用程序,这里我们将介绍Tendermint发出的其他ABCI请求,还有如何使用它们创建更先进的应用程序。想要获得关于ABCI完整的描述和使用方法,请查看说明

InitChain

在我们之前的应用中,我们创建完了所有的核心逻辑,但是我们还未指定存储要如何初始化。为此,我们使用了app.initChain方法,它会在应用程序首次启动时被Tendermint调用一次。

InitChain请求包含了多种Tendermint信息,比如共识层的参数和初始的验证人集合,还包含了一个不明确的应用特定字节的流——通常是JSON编码格式的。应用程序可以通过调用app.SetInitChainer方法决定用这些信息去做些什么。

例如,让我们引入一个GenesisAccount结构,其能被JSON编码并且是genesis文件的一部分。在InitChain期间,我们可以把某些账户落在存储上面:

TODO

如果我们包含了一个正确格式的GenesisAccount在Tendermint的genesis.json文件里,存储会同这些账户一起被初始化,它们就能发送交易了!

BeginBlock

BeginBlock在由DeliverTx生成处理任何交易之前,在每个区块开始时被调用。它包含了哪些验证人已经签名的信息。

EndBlock

EndBlock在DeliverTx处理完所有的交易之后,每个区块的结束时被调用。它允许应用程序去返回更新到验证人集合。

Commit

Commit在EndBlock之后调用。它持久化了应用程序的状态,并返回一个将会被下一个Tendermint区块包含的默克尔树的根哈希值。根哈希可以在Query中用作状态的默克尔树证明。

Query

Query允许对应用程序的存储按照一个路径去查询。

CheckTx

CheckTx没用做交易池。它只运行AnteHandler。消息处理直到交易已经被提交到区块时才开始,是代价非常之高的。AnteHandler授权发送者,确保他们有足够的手续费去支付。如果之后交易失败,发送者仍然会付这笔费用。

Basecoin

如我们所见,SDK提供了一个足够灵活的综合框架来创建状态机,定义它们的转变,还有对交易进行身份验证,执行消息,控制对存储的访问权限,以及更新验证人集合。

迄今为止,我们关注的都是如何把ABCI应用与证明隔离开来,还有去解释SDK的多种特征和灵活性。这里,我们将会把ABCI应用程序连接到Tendermint,这样我们就能运行一个完整的区块链节点,然后引入命令行和HTTP接口与之交互。

首先,我们讨论下源码要如何部署。

目录结构

TODO

Tendermint节点

因为Cosmos-SDK是由Go编写的,Cosmos-SDK应用程序能够被Tendermint编译成一个独立的二进制执行文件。当然,如同任何的ABCI应用程序那样,他们也可以独立运行,与Tendermint通过socket来交流。

要获得有关启动一个Tendermint全节点的详情,可以查看github.com/tendermint/tendermint/node中的NewNode函数。

Cosmos-SDK中的server包简化了把一个应用程序同一个Tendermint节点连接的过程。例如下面的main.go文件给出了使用我们之前创建的Basecoin来创建一个完整的全节点:

//TODO imports

func main() {
    cdc := app.MakeCodec()
    ctx := server.NewDefaultContext()

    rootCmd := &cobra.Command{
        Use:               "basecoind",
        Short:             "Basecoin Daemon (server)",
        PersistentPreRunE: server.PersistentPreRunEFn(ctx),
    }

    server.AddCommands(ctx, cdc, rootCmd, server.DefaultAppInit,
        server.ConstructAppCreator(newApp, "basecoin"))

    // prepare and add flags
    rootDir := os.ExpandEnv("$HOME/.basecoind")
    executor := cli.PrepareBaseCmd(rootCmd, "BC", rootDir)
    executor.Execute()
}

func newApp(logger log.Logger, db dbm.DB) abci.Application {
    return app.NewBasecoinApp(logger, db)
}

注意,我们为CLI使用了流行的cobra library,与viper library相呼应去管理配置。查看我们的cli library来获取详情

TODO:编译和运行这个二进制文件

运行basecoind二进制文件的选项对与运行tendermint是同样有效的。查看使用Tendermint来获取详情。

客户端

TODO

客户端

注意
我们正在对SDK clients的文档施工

Gaia CLI

注意
我们正在对Gaiacli和Gaiad的文档施工

密钥类型

有三种密钥的表现形式被使用:

生成密钥

我们需要一个账户的私钥公钥对(也就是sk, pk)去接收资金,发送交易,担保交易等

去生成一个新的key(默认使用ed5519椭圆加密算法):

gaiacli keys add <account_name>

接下来,你会创建一个密码去保护硬盘上的密钥。上面这条命令的输出会包含一个种子短语。请把它保存在安全的地方以免忘记密码!

如果你检查你的私钥,你将会看到<account_name>:

gaiacli keys show <account_name>

查看你可以使用的密钥:

gaiacli keys list

查看你节点的验证人公钥:

gaiad tendermint show_validator

警告
我们强烈建议对于多个keys不要使用相同的密码。Tendermint和跨链组织不会为资金的丢失负责。

获得Token

最好的获取token的方式是从Cosmos Testnet Faucet。如果水龙头对你不管用,尝试向#cosmos-validators索要。水龙头需要你想要用于质押的账户的cosmosaccaddr

在你的地址收到token之后,你可以查看的账户余额:

gaiacli account <account_cosmosaccaddr>

注意
当你查询一个0余额的账户时,你将会得到一个错误:No account with address <account_cosmosaccaddr> was found in the state. 这也可以发生在你于你的节点从链上完全同步数据之前去往地址里打钱。这也是常见的。
我们正在优化我们的错误提示信息!

发送Token

gaiacli send \
  --amount=10faucetToken \
  --chain-id=gaia-6002 \
  --name=<key_name> \
  --to=<destination_cosmosaccaddr>

注意
--amount接收这样的格式:--amount=<value|coin_name>

现在,查看源账户和目的账户的新余额:

gaiacli account <account_cosmosaccaddr>
gaiacli account <destination_cosmosaccaddr>

你还可以通过--block来查看你的余额在某个给定区块时的状态:

gaiacli account <account_cosmosaccaddr> --block=<block_height>

委托

在即将到来的主网,你可以委托atom给一个验证人。这些委托人可以收到验证人费用收益的其中一部分。阅读Cosmos Token Model获取更多信息。

抵押Token

在测试网络中,我们委托steak而不是atom。这里,你可以抵押tokens给一个测试网络的验证人:

gaiacli stake delegate \
  --amount=10steak \
  --address-delegator=<account_cosmosaccaddr> \
  --address-validator=$(gaiad tendermint show_validator) \
  --name=<key_name> \
  --chain-id=gaia-6002

当token被抵押时,它们和网络中所有其他的抵押的token都在池子里。验证人和委托人按照他们在池子里的股权来获取一定比例的分红。

注意
不要使用超过你持有数量的steak!你可以通过Faucet获得更多的steak!

赎回Token

如果验证人有任何理由的不端行为,或者你想要赎回一定数量的tokens,请使用下面的命令。你可以赎回特定数量的shares(比如:12.1)或者所有(MAX)。

gaiacli stake unbond \
  --address-delegator=<account_cosmosaccaddr> \
  --address-validator=$(gaiad tendermint show_validator) \
  --shares=MAX \
  --name=<key_name> \
  --chain-id=gaia-6002

你可以查看你的余额和你抵押的委托金额,来看看赎回操作是否成功。

gaiacli account <account_cosmosaccaddr>

gaiacli stake delegation \
  --address-delegator=<account_cosmosaccaddr> \
  --address-validator=$(gaiad tendermint show_validator) \
  --chain-id=gaia-6002

轻客户端

Note
我们正在对LCD的文档施工

上一篇 下一篇

猜你喜欢

热点阅读