EOS教程

EOS 教程

2018-01-10  本文已影响2189人  cenkai88

1. 账户与钱包

注意 本教程是基于 测试私网,但稍作修改就可以运用在测试公网上。

您将学到

您将学到如何创建钱包、管理钱包及其keys并通过eosc使用钱包和区块链交互。

本教程的目标群体

本教程目标群体是希望学习钱包和账户管理的人。我们将尽可能地介绍eosc以及EOS钱包和账户是如何交互的。有一定基础的用户可查看参考命令

前提条件

注意: 当使用docker安装时,命令可能需要稍作改动。

1.1 创建并管理钱包

打开终端,进入EOS目录

这会是我们更方便地操作eosc,它是一个与eosdeos-walletd交互的命令行工具。

$ cd /path_to_eos/build/programs/eosc

首先您要用eoscwallet create创造一个钱包

$ eosc wallet create
Creating wallet: default
Save password to use in the future to unlock this wallet.
Without password imported keys will not be retrievable.
"A MASTER PASSWORD"

一个叫default的钱包现在已经在eos-walletd里了,并且返回了一个该钱包的一个master password。请将这个密码安全地保存起来。这个密码是用来解锁(解密)您的钱包文件的。

该钱包文件叫做default.wallet,被保存在了您的EOS目录(您也可以在启动eos-walletd--data-dir制定特定目录)下的data-dir文件夹里。

管理多个钱包和钱包名

eosc能够管理多个钱包。每个钱包被各自的master password保护起来。下面的例子创建了另一个钱包并且展示了如何用 -n 参数给他命名

$ eosc wallet create -n periwinkle
Creating wallet: periwinkle
Save password to use in the future to unlock this wallet.
Without password imported keys will not be retrievable.
"A MASTER PASSWORD"

现在确认一下钱包已经用您指定的名字创建出来了。

$ eosc wallet list
Wallets:
[
  "default *",
  "periwinkle *"
]

每个钱包后面的星号 (*) 很重要,他们表示钱包已解锁。方便起见,我们用create wallet创建出来的钱包默认是解锁的。

wallet lock锁住第二个钱包

$ eosc wallet lock -n periwinkle
Locked: 'periwinkle'

再次运行wallet list,您就可以看到第二个星号不见了,表示该钱包已上锁。

$ eosc wallet list
Wallets:
[
  "default *",
  "periwinkle"
]

解锁一个有名字的钱包需要用wallet unlock命令并用-n参数指定钱包名,然后输入钱包的 master密码(您可以粘贴密码)。下面我们复制第二个钱包的master密码,执行此命令并粘贴密码后回车。然后您需要确认操作。

$ eosc wallet unlock -n periwinkle

eosc会告诉您钱包上锁了

Unlocked: 'periwinkle'

注意: 您也可以用 --password 参数后跟master密码,但是这会导致您的密码在控制台历史当中被明文地记录下来。

现在查看一下钱包

$ eosc wallet list
Wallets:
[
  "default *",
  "periwinkle *"
]

好的,periwinkle钱包后面有星号,表示它解锁了。

注意: 使用'default'钱包不需要使用-n参数

现在重启 eos-walletd,退回到您调用eosc的路径下运行以下命令

$ eosc wallet list
Wallets:
[]

有意思,钱包去哪了呢?

钱包需要被打开,因为您关闭过eos-walletd,钱包并不在打开状态,运行以下命令:

$ eosc wallet open
$ eosc wallet list
Wallets:
[
  "default"
]

好多了。

注意: 如果您希望打开一个有名字的钱包,您可以$ eosc wallet open -n periwinkle,学会了吗? ;)

从上面的信息中您可以看到钱包是默认锁住的,把它解锁才能进行下面的操作。
执行wallet unlock命令并在要求输入密码时粘贴上default 钱包的master密码。

$ eosc wallet unlock
Unlocked: 'default'

然后检查钱包是否已解锁。

$ eosc wallet list
Wallets:
[
  "default *"
]

钱包名后面有星号,已解锁,非常好。

您已经学会如何创建多个钱包及如何用eosc操作他们了。但空钱包没什么意义,现在让我们导入keys。

1.2 生成并导入EOS Keys

生成EOS key对有好几种方法,本教程主要讲eosccreate key命令的方法。

生成两个密钥对

$ eosc create key
Private key:###
Public key: ###
$ eosc create key
Private key:###
Public key: ###

现在您有两个EOS 密钥对了。此时,他们只是最初始的密钥对,并没有authority。

如果您一直根据上面来操作,您的default钱包应该是打开且解锁的。

下面,我们执行wallet import命令两次,每次导入我们之前所生成的一个私钥到您的 default钱包。

$ eosc wallet import ${private_key_1}

然后是第二个私钥

$ eosc wallet import ${private_key_2}

如果顺利,每次wallet import命令都会返回您的私钥对应的公钥,您的控制台会是这样的:

$ eosc wallet import ${private_key_1}
imported private key for: ${public_key_1}
$ eosc wallet import ${private_key_2}
imported private key for: ${public_key_2}

我们用wallet keys看看加载了哪些密钥

$ eosc wallet keys
[[
    "EOS6....",
    "5KQwr..."
  ],
  [
    "EOS3....",
    "5Ks0e..."
  ]
]

钱包锁起来的时候,这些密钥也会被保护起来。要从一个被锁住的钱包中拿到密钥需要有钱包创建时的master密码。因为钱包文件本身是加密的,备份密钥对并不是一定要做的,但最好还是在一个安全的地方备份您的钱包文件。

1.3 备份钱包

现在您的钱包里已经有密钥对了,您最好养成备份但习惯,以防各种各样的原因造成钱包丢失。比如使用u盘。没有密码,钱包是强熵加密的,想拿到里面的密钥是非常难的 (基本不可能的)。

您可以在data-dir文件夹下找到您的钱包文件。如果您在启动eos时用--data-dir参数指定过,您可以在/path/to/eos/build/programs/eosd中找到(eos的具体路径因系统不同而有不同)。

$ cd /path_to_eos/build/programs/eosd && ls
blockchain   blocks   config.ini   default.wallet   periwinkle.wallet

进入文件夹后您将看到两个文件:default.walletperiwinkle.wallet。把他们保存起来(熟能生巧!)。

1.4 创建账户

如果您用的是测试公网,您需要有一个创世allocation或者从水龙头账户申请一个账户。下面操作时请进行适当改动 (提示:应当用您自己的账户替换 inita 账户)

首先,我们看看 create account 命令及其必需参数:

$ eosc create account inita ${desired_account_name} ${public_key_1} ${public_key_2}

create account命令必需参数的解读

您之前生成了两个密钥对,您可以翻看控制台前面的记录或者执行wallet keys来查看。

$ eosc wallet keys
[[
    "EOS6....",
    "5KQwr..."
  ],
  [
    "EOS3....",
    "5Ks0e..."
  ]
]

提醒一下,公钥是以EOS...开头。在您给密钥分配authority前,上面的密钥都是初始的。which one you decide to user for active and owner are inconsequential until you have created your account.

注意, 您的owner密钥等于对您账户的全面控制,而active密钥等于对您账户资金的全面控制。

用您之前所学的,替换命令中的占位符然后回车:

$ eosc create account inita ${desired_account_name} ${public_key_1} ${public_key_2}

您看到了一个提到"authorities"的报错了吗?不用着急,我是故意让您这么做的。您看到报错是因为您没有加载@inita这个账户的密钥。

inita 的密钥存在 config.ini里。但为方便起见,我将其复制了出来放在了下面。直接运行下面的命令即可。

$ eosc wallet import 5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3

将会返回

imported private key for: EOS6MRyAjQq8ud7hVNYcfnVPJqcVpscN5So8BhtHuGYqET5GDW5CV

现在 @inita 账户的密钥已经加载,重新回到报错之前的create account 命令并回车。

顺利的话 eosc 将返回一个含有transaction ID的JSON对象,类似于下面:

{
  "transaction_id": "6acd2ece68c4b86c1fa209c3989235063384020781f2c67bbb80bc8d540ca120",
  "processed": {
    "refBlockNum": "25217",
    "refBlockPrefix": "2095475630",
    "expiration": "2017-07-25T17:54:55",
    "scope": [
      "eos"...

太好了!您现在已经在区块链上已经有一个账户了。

您做的很棒,您创建了一个钱包,学习了一些钱包是如何工作、生成密钥及如何把密钥导入钱包的知识。

2. 货币合约概览

目标

下面的教程将帮助用户了解github仓库中的样例货币合约

概览

货币合约处理的是将货币从一个账户转到另一个账户的工作,而不同账户的余额保存在每个用户的本地scope中。

Action

目前本合约只有一个action:
currency_transfer:将货币从一个账户转到另一个账户。

开始!

智能合约分为三个文件:

currency.hpp 合约中的声明和数据结构信息存在头文件中
currency.cpp 合约的逻辑和实现
currency.abi 提供给用户交互的接口定义

头文件: currency.hpp

首先导入所需库并定义您的命名空间

// 导入所需库

#include <eoslib/eos.hpp>   // Generic eos library, i.e. print, type, math, etc
#include <eoslib/token.hpp> // Token usage
#include <eoslib/db.hpp>    // Database access

namespace currency {
    // Your code here
}

然后加入一个货币token。 It’s in fact a uin64_t wrapper which checks for proper types and under/overflows for standard-compatible token messages

typedef eosio::token<uint64_t,N(currency)> currency_tokens;

我们action的结构如下所示:

struct transfer {
    account_name from;          //转出账户
    account_name to;            //转入账户
    currency_tokens quantity;   //转账金额
};

另外我们把余额信息存在表里。表是如下定义的:

using accounts = eosio::table<N(defaultscope),N(currency),N(account),account,uint64_t>;

一旦表定义了,需要储存的数据结构(在我们的例子中是“账户”)也需要被定义。这是在另一个struct中完成的:

struct account {
    //Constructor
    account( currency_tokens b = currency_tokens() ):balance(b){}

    //key是常量,因为每个scope/currency/accounts只有一条记录
    const uint64_t key = N(account);

    //账户的token数量
    currency_tokens balance;

    // 用于检查账户是否为空的方法
    // 如果余额为0返回true
    bool is_empty()const { return balance.quantity == 0; }
};

这个结构包含一个构造器和一个用于判断账户是否为空的标准函数。

需要注意的是,key的变量类型需要与之前在定义表时 (第五个函数)定义的类型一致。

为方便起见,我们增加了一个存取器函数来获取所有者的账户信息,返回存在owner/TOKEN_NAME/account/account的信息。此函数存在头文件中以提供第三方获取用户余额的能力。

inline account get_account( account_name owner ) {
    account owned_account;
    accounts::get( owned_account, owner );
    return owned_account;
}

注意: accounts:get函数返回账户所有者。为应对账户不存在的情况,它返回一个默认结构的账户。

源代码文件:currency.cpp

#include <currency/currency.hpp>

// The init() and apply() methods must have C calling convention

extern "C" {
    // Only called once
    void init() {
    }

    // The apply method implements the dispatch of events to this contract
    void apply( uint64_t code, uint64_t action_name ) {
        // Put your message handler here
    }
} // extern "C"

所有的合约都有以上的骨架,每个合约都需要有以上的函数:

Init() 在一个合约的生命周期开始时被调用一次。可用它来设置环境来让合约正确运行。

Apply( uint64_t code, uint64_t action_name) 被用作一个message的槽子。每次有message发给合约时,此函数即开始调用。它的两个参数含义如下:

在货币合约中,init() 函数如下所示:

void init() {
    account owned_account;

    //初始化货币账户,除非账户不存在
    if ( !accounts::get( owned_account, N(currency) )) {
        store_account( N(currency),
        account( currency_tokens(1000ll\*1000ll\*1000ll) ) );
    }
}

合约第一次运行时,它会检查currency账户是否有建立表且货币余额记录在表中。如果没有建立表就会生成一个新表,余额为1000,000,000,这样货币合约就成为了总量1000,000,000的货币单位的第一个所有者。

message槽如下所示:

void apply( uint64_t code, uint64_t action ) {
    if( code == N(currency) ) {
        if( action == N(transfer) )
            account::apply_currency_transfer( current_message<account::transfer >() );
    }
}

最好在上面的样例代码中实现一个message过滤器,使得合约只处理那些正确的messages并在过滤后调用message处理器。

注意 current_message() 会在message传给特定处理器之前调用,它是用来将合约收到的message转为struct T的。

Message处理器

实际上的货币转账是在这里操作的:

void apply_currency_transfer( const account::transfer& transfer_msg )
{
    require_notice( transfer_msg.to, transfer_msg.from );
    require_auth( transfer_msg.from );

    auto from = get_account( transfer_msg.from );
    auto to = get_account( transfer_msg.to );

    from.balance -= transfer_msg.quantity;
    to.balance += transfer_msg.quantity;

    store_account( transfer_msg.from, from );
    store_account( transfer_msg.to, to );
}

代码非常直接,从转出账户扣除转账金额并增加到转入账户。

require_notice函数是一个inline action,使得把收到的message转到另一个账户成为可能。此例中message被转发给了转入账户和转出账户。这是非常有用的功能,因为它把那些“被通知的账户”引入链上并发挥功能。
require_auth函数使得message被正确地签名。在这个例子中,转出账户需要签名,这个transaction才能被正确地处理。

注意我们正在使用头文件里的get_account函数来获得正确的账户对象。

Since we are using tokens, automatic over and underflow assertions are being backed into the actual subtraction and addition operations.

最后通过store_account函数更新余额。

Store_account

这个函数是用来实际处理余额的储存的:

void store_account( account_name current_account, const account& value ) {
    if( a.is_empty() ) {
        accounts::remove( value, current_account);
    } else {
        accounts::store( value, current_account);
    }
}

有趣的是,如果账户(也就是在current_account的scope下创建的表)是空的,他就会被移除,这是因为只要有钱转到不存在的账户里,表就会被新建出来。

移除不需要的表是一种节约资源的做法,是一种写智能合约的最佳实践。

注意: 当把上面的样例代码和仓库里的实际代码比较时,请注意为了账户可以更简单的重命名,我们使用了TOKEN_NAME作为一种#define。上面的代码中,我们用账户名替代了TOKEN_NAME以使得代码更清晰。

ABI文件: currency.abi

Abi (即Application Binary Interface) 发送的message和二进制版本的智能合约之间的接口。我们先来看一个的通用版本,它包括如下对象:

{
    "structs": \[{
        "name": "...",
        "base": "...",  
        "fields": { ... }
    }, ...\],
    "actions": \[{
        "action_name": "...",
        "type": "..."
    }, ...\],
    "tables": \[{
        "table_name": "...",
        "type": "...",
        "key_names" : \[...\],
        "key_types" : \[...\]
    }, ...\]
}

struct对象

根据合约中头文件的信息,可以创建大多数ABI。因此我们从数据结构开始。头文件中有两个结构:

struct transfer {
    account_name from;
    account_name to;
    currency_tokens quantity;
};

struct account {
    account( currency_tokens b = currency_tokens() ):balance(b){}
    const uint64_t key = N(account);
    currency_tokens balance;
    bool is_empty()const { return balance.quantity == 0; }
};

这些结构就生成了如下ABI信息:

"structs": \[{
    "name": "transfer",
    "base": "",
    "fields": {
        "from": "account_name",
        "to": "account_name",
        "quantity": "uint64"
    }
},{
    "name": "account",
    "base": "",
    "fields": {
        "key": "name",
        "balance": "uint64"
    }
}\]

action对象

Action 对象也是类似的对应。在这里我们在货币合约中有一个叫 “transfer” action。看起来和下面的ABI文件类似:

"actions": \[{
    "action_name": "transfer",
    "type": "transfer"
}\]

table对象

头文件中, a single index called “account” table定义如下:

eosio::table<N(defaultscope),N(currency),N(account),account,uint64_t>;

这张表就转为下面的ABI对象:

"tables": \[{
    "table_name": "account",
    "type": "account",
    "index_type": "i64",
    "key_names" : \["key"\],
    "key_types" : \["name"\]
}\]

这样就组成了ABI文件。

部署与运行

现在三个文件 (currency.hpp, currency.cpp, currency.abi) 都可以通过命令行部署了:

$ eosc set contract currency currency.wast currency.abi

请确认钱包已经解锁且含有 currency 的密钥。部署后合约的action可以通过命令行这样触发:

$ eosc push message currency transfer ‘{“from”:“currency”,“to”:“tester”,“quantity”:50}’ -S currency -S tester -p currency@active

3. “Hello World”智能合约

为方便起见,我们创造了一个叫eoscpp的工具来引导产生新的智能合约。您需要先安装eosio/eos并把${CMAKE_INSTALL_PREFIX}/bin放入您的环境变量,它才能正常工作。

$ eoscpp -n hello
$ cd hello
$ ls

上面在'./hello'文件夹创建了一个新的空工程,里面有三个文件:

hello.abi hello.hpp hello.cpp

我们看一下最简单的合约:

$ cat hello.cpp

#include <hello.hpp>

/**
 *  The init() and apply() methods must have C calling convention so that the blockchain can lookup and
 *  call these methods.
 */
extern "C" {

    /**
     *  This method is called once when the contract is published or updated.
     */
    void init()  {
       eosio::print( "Init World!\n" );
    }

    /// The apply method implements the dispatch of events to this contract
    void apply( uint64_t code, uint64_t action ) {
       eosio::print( "Hello World: ", eosio::name(code), "->", eosio::name(action), "\n" );
    }

} // extern "C"

这个合约实现了两个入口, initapply。它所做的只是记录提交的messages而并不作检查。只要区块生产者同意,任何人在任何时间都可以提交任何message。但没有所需的签名,合约将因消耗带宽被收费。

您可以将合约像这样编译成文本版本的WASM (.wast) :

$ eoscpp -o hello.wast hello.cpp

部署您的合约

现在您已经编译了您的应用,我们可以部署了。这需要您先:

  1. 启动 eosd 并打开钱包插件
  2. 新建钱包,导入至少一个账户的密钥
  3. 解锁钱包

如果您的钱包里有${account}的密钥且已经解锁,您就可以用下面的命令把合约上传到区块链上

$ eosc set contract ${account} hello.wast hello.abi
Reading WAST...
Assembling WASM...
Publishing contract...
{
  "transaction_id": "1abb46f1b69feb9a88dbff881ea421fd4f39914df769ae09f66bd684436443d5",
  "processed": {
    "ref_block_num": 144,
    "ref_block_prefix": 2192682225,
    "expiration": "2017-09-14T05:39:15",
    "scope": [
      "eos",
      "${account}"
    ],
    "signatures": [
      "2064610856c773423d239a388d22cd30b7ba98f6a9fbabfa621e42cec5dd03c3b87afdcbd68a3a82df020b78126366227674dfbdd33de7d488f2d010ada914b438"
    ],
    "messages": [{
        "code": "eos",
        "type": "setcode",
        "authorization": [{
            "account": "${account}",
            "permission": "active"
          }
        ],
        "data": "0000000080c758410000f1010061736d0100000001110460017f0060017e0060000060027e7e00021b0203656e76067072696e746e000103656e76067072696e7473000003030202030404017000000503010001071903066d656d6f7279020004696e69740002056170706c7900030a20020600411010010b17004120100120001000413010012001100041c00010010b0b3f050041040b04504000000041100b0d496e697420576f726c64210a000041200b0e48656c6c6f20576f726c643a20000041300b032d3e000041c0000b020a000029046e616d6504067072696e746e0100067072696e7473010004696e697400056170706c790201300131010b4163636f756e744e616d65044e616d6502087472616e7366657200030466726f6d0b4163636f756e744e616d6502746f0b4163636f756e744e616d6506616d6f756e740655496e743634076163636f756e740002076163636f756e74044e616d650762616c616e63650655496e74363401000000b298e982a4087472616e736665720100000080bafac6080369363401076163636f756e7400076163636f756e74"
      }
    ],
    "output": [{
        "notify": [],
        "deferred_transactions": []
      }
    ]
  }
}

如果您查看eosd 进程的输出您将看到:

...] initt generated block #188249 @ 2017-09-13T22:00:24 with 0 trxs  0 pending
Init World!
Init World!
Init World!

您可以看到"Init World!"被执行了三次,这其实并不是个bug。区块链处理transactions的流程是:

1: eosd收到一个新transaction (正在验证的transaction)

2 : eosd开始产出区块

3rd : eosd如同从网络上获得区块一样将区块追加到链上。

此时,您的合约就可以开始接受messages了。因为默认message处理器接受所有messages,我们可以发送任何我们想发的东西。我们试一下发一个空的message:

$ eosc push message ${account} hello '"abcd"' --scope ${account}

此命令将"hello"message及16进制字符串"abcd"所代表的二进制文件传出。注意,后面我们将展示如何定义ABI来用一个好看易读的JSON对象替换16进制字符串。以上,我们只是想证明“hello”类型的message是如何发送到账户的。

结果是:

{
  "transaction_id": "69d66204ebeeee68c91efef6f8a7f229c22f47bcccd70459e0be833a303956bb",
  "processed": {
    "ref_block_num": 57477,
    "ref_block_prefix": 1051897037,
    "expiration": "2017-09-13T22:17:04",
    "scope": [
      "${account}"
    ],
    "signatures": [],
    "messages": [{
        "code": "${account}",
        "type": "hello",
        "authorization": [],
        "data": "abcd"
      }
    ],
    "output": [{
        "notify": [],
        "deferred_transactions": []
      }
    ]
  }
}

如果您继续查看eosd的输出,您将在屏幕上看到:

Hello World: ${account}->hello
Hello World: ${account}->hello
Hello World: ${account}->hello

再一次,您的合约在transaction被第三次应用并成为产出的区块之前被执行和撤销了两次。

Message名的限定

Message的类型实际上是base32编码的64位整数。所以Message名的前12个字符需限制在字母a-z, 1-5, 以及'.' 。第13个以后的字符限制在前16个字符('.' and a-p)。

ABI - Application Binary Interface

Application Binary Interface (ABI)是一个基于JSON的描述文件,是关于转换JSON和二进制格式的用户actions的。ABI还描述了如何将数据库状态和JSON的互相转换。一旦您通过ABI描述了您的合约,开发者和用户就能够用JSON和您的合约无缝交互了。

我们正在开发使用C++源码自动生成ABI的工具,但目前为止您还是只能手动生成。

这里是一个合约的骨架ABI的例子:

{
  "types": [{
      "new_type_name": "account_name",
      "type": "name"
    }
  ],
  "structs": [{
      "name": "transfer",
      "base": "",
      "fields": {
        "from": "account_name",
        "to": "account_name",
        "quantity": "uint64"
      }
    },{
      "name": "account",
      "base": "",
      "fields": {
        "account": "name",
        "balance": "uint64"
      }
    }
  ],
  "actions": [{
      "action": "transfer",
      "type": "transfer"
    }
  ],
  "tables": [{
      "table": "account",
      "type": "account",
      "index_type": "i64",
      "key_names" : ["account"],
      "key_types" : ["name"]
    }
  ]
}

您肯定注意到了这个ABI 定义了一个叫transfer的action,它的类型也是transfer。这就告诉EOS.IO当${account}->transfer的message发生时,它的payload是transfer类型的。 transfer类型是在structs的列表中定义的,其中有个对象,name属性是transfer

...
  "structs": [{
      "name": "transfer",
      "base": "",
      "fields": {
        "from": "account_name",
        "to": "account_name",
        "quantity": "uint64"
      }
    },{
...

这部分包括from, toquantity等字段。这些字段都有对应的类型:account_nameuint64account_nametypes 列表中被定义为name的别名,而name是一个内置类型,用于用base32编码uint64_t (比如账户名)。

{
  "types": [{
      "new_type_name": "account_name",
      "type": "name"
    }
  ],
...

在弄清骨架ABI后,我们可以构造一个transfer类型的message:

eosc push message ${account} transfer '{"from":"currency","to":"inita","quantity":50}' --scope initc
2570494ms thread-0   main.cpp:797                  operator()           ] Converting argument to binary...
{
  "transaction_id": "b191eb8bff3002757839f204ffc310f1bfe5ba1872a64dda3fc42bfc2c8ed688",
  "processed": {
    "ref_block_num": 253,
    "ref_block_prefix": 3297765944,
    "expiration": "2017-09-14T00:44:28",
    "scope": [
      "initc"
    ],
    "signatures": [],
    "messages": [{
        "code": "initc",
        "type": "transfer",
        "authorization": [],
        "data": {
          "from": "currency",
          "to": "inita",
          "quantity": 50
        },
        "hex_data": "00000079b822651d000000008040934b3200000000000000"
      }
    ],
    "output": [{
        "notify": [],
        "deferred_transactions": []
      }
    ]
  }
}

如果您继续观察eosd的输出,您将看到:

Hello World: ${account}->transfer
Hello World: ${account}->transfer
Hello World: ${account}->transfer

处理转账Message的参数

根据ABI,transfer message应该是如下格式的:

     "fields": {
         "from": "account_name",
         "to": "account_name",
         "quantity": "uint64"
     }

我们也知道account_name -> uint64表示这个message的二进制表示如同:

struct transfer {
    uint64_t from;
    uint64_t to;
    uint64_t quantity;
};

EOS.IO的C API通过Message API提供获取message的payload的能力:

uint32_t message_size();
uint32_t read_message( void* msg, uint32_t msglen );

让我们修改hello.cpp来打印出消息内容:

#include <hello.hpp>

/**
 *  The init() and apply() methods must have C calling convention so that the blockchain can lookup and
 *  call these methods.
 */
extern "C" {

    /**
     *  This method is called once when the contract is published or updated.
     */
    void init()  {
       eosio::print( "Init World!\n" );
    }

    struct transfer {
       uint64_t from;
       uint64_t to;
       uint64_t quantity;
    };

    /// The apply method implements the dispatch of events to this contract
    void apply( uint64_t code, uint64_t action ) {
       eosio::print( "Hello World: ", eosio::name(code), "->", eosio::name(action), "\n" );
       if( action == N(transfer) ) {
          transfer message;
          static_assert( sizeof(message) == 3*sizeof(uint64_t), "unexpected padding" );
          auto read = readMessage( &message, sizeof(message) );
          assert( read == sizeof(message), "message too short" );
          eosio::print( "Transfer ", message.quantity, " from ", eosio::name(message.from), " to ", eosio::name(message.to), "\n" );
       }
    }

} // extern "C"

这样我们就可以重编译并部署了:

eoscpp -o hello.wast hello.cpp 
eosc set contract ${account} hello.wast hello.abi

eosd因为重部署将再次调用init()

Init World!
Init World!
Init World!

然后我们执行transfer:

$ eosc push message ${account} transfer '{"from":"currency","to":"inita","quantity":50}' --scope ${account}
{
  "transaction_id": "a777539b7d5f752fb40e6f2d019b65b5401be8bf91c8036440661506875ba1c0",
  "processed": {
    "ref_block_num": 20,
    "ref_block_prefix": 463381070,
    "expiration": "2017-09-14T01:05:49",
    "scope": [
      "${account}"
    ],
    "signatures": [],
    "messages": [{
        "code": "${account}",
        "type": "transfer",
        "authorization": [],
        "data": {
          "from": "currency",
          "to": "inita",
          "quantity": 50
        },
        "hex_data": "00000079b822651d000000008040934b3200000000000000"
      }
    ],
    "output": [{
        "notify": [],
        "deferred_transactions": []
      }
    ]
  }
}

后面我们将看到eosd有如下输出:

Hello World: ${account}->transfer
Transfer 50 from currency to inita
Hello World: ${account}->transfer
Transfer 50 from currency to inita
Hello World: ${account}->transfer
Transfer 50 from currency to inita

使用 C++ API来读取 Messages

目前我们使用是C API因为这是EOS.IO直接暴露给WASM虚拟机的最底层的API。幸运的是,eoslib提供了一个更高级的API,移除了很多不必要的代码。

/// eoslib/message.hpp
namespace eosio {
     template<typename T>
     T current_message();
}

我们可以向下面一样更新 hello.cpp 把它变得更简洁:

#include <hello.hpp>

/**
 *  The init() and apply() methods must have C calling convention so that the blockchain can lookup and
 *  call these methods.
 */
extern "C" {

    /**
     *  This method is called once when the contract is published or updated.
     */
    void init()  {
       eosio::print( "Init World!\n" );
    }

    struct transfer {
       eosio::name from;
       eosio::name to;
       uint64_t quantity;
    };

    /// The apply method implements the dispatch of events to this contract
    void apply( uint64_t code, uint64_t action ) {
       eosio::print( "Hello World: ", eosio::name(code), "->", eosio::name(action), "\n" );
       if( action == N(transfer) ) {
          auto message = eosio::current_message<transfer>();
          eosio::print( "Transfer ", message.quantity, " from ", message.from, " to ", message.to, "\n" );
       }
    }

} // extern "C"

您可以注意到我们更新了transfer的struct,直接使用eosio::name 类型并将read_message前后的类型检查压缩为一个单个的current-Message调用。

在编译和上传后,您将看到和C语言版本同样的结果。

获取发送者的Authority来进行转账

合约最普遍的需求之一就是定义谁可以进行这样的操作。比如在货币转账的例子里,我们就需要定义为from字段的账户核准此message。

EOS.IO软件负责加强和验证签名,您需要做的是获取所需的authority。

    ...
    void apply( uint64_t code, uint64_t action ) {
       eosio::print( "Hello World: ", eosio::name(code), "->", eosio::name(action), "\n" );
       if( action == N(transfer) ) {
          auto message = eosio::current_message<transfer>();
          eosio::require_auth( message.from );
          eosio::print( "Transfer ", message.quantity, " from ", message.from, " to ", message.to, "\n" );
       }
    }
    ...

建立和部署后,我们可以再试一次转账:

 eosc push message ${account} transfer '{"from":"initb","to":"inita","quantity":50}' --scope ${account}
 1881603ms thread-0   main.cpp:797                  operator()           ] Converting argument to binary...
 1881630ms thread-0   main.cpp:851                  main                 ] Failed with error: 10 assert_exception: Assert Exception
 status_code == 200: Error
 : 3030001 tx_missing_auth: missing required authority
 Transaction is missing required authorization from initb
     {"acct":"initb"}
         thread-0  message_handling_contexts.cpp:19 require_authorization
...

如果您查看eosd ,您将看到:

Hello World: initc->transfer
1881629ms thread-0   chain_api_plugin.cpp:60       operator()           ] Exception encountered while processing chain.push_transaction:
...

这表示此操作尝试请求应用您的transaction,打印出了初始的"Hello World",然后当eosio::require_auth没能成功获取initb账户的authorization后,操作终止了。

我们可以通过让eosc增加所需的permission来修复这个问题:

 eosc push message ${account} transfer '{"from":"initb","to":"inita","quantity":50}' --scope ${account} --permission initb@active

--permission 命令定义了账户和permission等级,此例中我们使用active authority,也就是默认值。

这次转账应该就成功了,如同我们之前看到的一样。

Aborting a Message on Error

绝大多数合约开发中有非常多的前置条件,比如转账的金额要大于0。如果用户尝试进行一个非法action,合约必须终止且已做出的任何变动都必须自动回滚。

    ...
    void apply( uint64_t code, uint64_t action ) {
       eosio::print( "Hello World: ", eosio::name(code), "->", eosio::name(action), "\n" );
       if( action == N(transfer) ) {
          auto message = eosio::current_message<transfer>();
          assert( message.quantity > 0, "Must transfer a quantity greater than 0" );
          eosio::require_auth( message.from );
          eosio::print( "Transfer ", message.quantity, " from ", message.from, " to ", message.to, "\n" );
       }
    }
    ...

我们编译、部署并尝试进行一次金额为0的转账:

 $ eoscpp -o hello.wast hello.cpp
 $ eosc set contract ${account} hello.wast hello.abi
 $ eosc push message ${account} transfer '{"from":"initb","to":"inita","quantity":0}' --scope initc --permission initb@active
 3071182ms thread-0   main.cpp:851                  main                 ] Failed with error: 10 assert_exception: Assert Exception
 status_code == 200: Error
 : 10 assert_exception: Assert Exception
 test: assertion failed: Must transfer a quantity greater than 0

4. Tic-Tac-Toe

目标

下面的教程将引导用户构建一个样例的PvP的游戏合约。我们用tic tac toe游戏来举例。本教程的结果在 这里.

前提

在此游戏中,我们用标准的3x3 tic tac toe板。玩家们有两种角色hostchallenger。Host 永远是先手。每个玩家只能同时玩两局比赛,一局是第一个玩家是host另一局是第二个玩家是host。

游戏板

(0,0) (1,0) (2,0)
(0,0) - o x
(0,1) - x -
(0,2) x o o

不同于传统的tic tac toe游戏,我们不用ox ,而用1 代表host的一步,2代表challenger的一步,0代表空各自。而且我们使用一维数组来保存游戏数据。因此:

(0,0) (1,0) (2,0)
(0,0) - o x
(0,1) - x -
(0,2) x o o

假设 x ,是host上面的游戏板可表示为[0, 2, 1, 0, 1, 0, 1, 2, 2]

Action

用户需要用下列actions来和合约交互:

合约账户

在下面的教程中,我们将把合约添加到一个叫tic.tac.toe的账户中。为防止tic.tac.toe的账户名被占用,您可以用其他的账户名,只需要在代码里面用您的账户名替换掉tic.tac.toe 。如果您没有账户,请先创建。

$ eosc create account ${creator_name} ${contract_account_name} ${contract_pub_owner_key} ${contract_pub_active_key} --permission ${creator_name}@active
# e.g. $ eosc create account inita tic.tac.toe  EOS4toFS3YXEQCkuuw1aqDLrtHim86Gz9u3hBdcBw5KNPZcursVHq EOS7d9A3uLe6As66jzN8j44TXJUqJSK3bFjjEEqR4oTvNAB3iM9SA --permission inita@active

请先解锁钱包并导入私钥,否则上面的命令将失败。

开始!

我们将创建三个文件:

定义结构

让我们先从定义合约结构开始。打开tic_tac_toe.hpp 并且从下面的模版代码开始

// Import necessary library
#include <eoslib/eos.hpp> // Generic eos library, i.e. print, type, math, etc
#include <eoslib/db.hpp> // Database access

using namespace eosio;
namespace tic_tac_toe {
    // Your code here
}

游戏表

对于这个合约我们需要把游戏列表存在表中,我们来定义它:

...
namespace tic_tac_toe {
    ...
    using Games = eosio::table<N(tic.tac.toe),N(tic.tac.toe),N(games),game,uint64_t>;
}

NB: 如果您要把合约上传到其他账户上,请用您的账户名替代tic.tac.toe

第一个参数定义表的默认scope,比如当有没有指定scope的数据存入表中时,它就会使用这个账户。

游戏结构

下面我们来定义游戏的结构。注意在代码中,定义结构需要在定义表之前。

...
namespace tic_tac_toe {
    struct PACKED(game) {
        // 默认 constructor
        game() {};
        // Constructor
        game(account_name challenger, account_name host):challenger(challenger), host(host), turn(host) {
            // 初始化游戏板
            initialize_board();
        };
        // challenger的账户名,也是表中的key
        account_name     challenger;
        // host的账户名
        account_name     host;
        // 轮到谁走, = 可能是host或challenger的账户名
        account_name     turn; 
        // 赢家, = 空或平手或者是host或challenger的账户名
        account_name     winner = N(none); 
        // 游戏板列表的长度,需放在游戏板列表的前面一个。有此字段abi序列化工具才能正确的可以打包写入数据库或从数据库拆包数据
        uint8_t          board_len = 9;
        // 游戏板列表
        uint8_t          board[9]; 

        // 用空格初始化游戏板
        void initialize_board() {
            for (uint8_t i = 0; i < board_len ; i++) {
            board[i] = 0;
            }
        }

        // 重置游戏
        void reset_game() {
            initialize_board();
            turn = host;
            winner = N(none);
        }
    };
    ...
}

记住,在前面表定义的时候,我们声明表的key数据类型是uint64_t。因此,在前面的游戏结构中,结构中前sizeof(uint64_t)字节长度的数据将被当成表的key。顺便一提,account_name只是uint64_t的别名。

Action 结构

Create

要新建游戏,我们需要 host 账户名和 challenger 账户名。

...
namespace tic_tac_toe {
    ...
    struct create {
        account_name   challenger;
        account_name   host;
    };
    ...
}
Restart

要重启游戏,我们需要host 账户名和 challenger 账户名来找到该游戏。而且,我们需要指定是谁重启了游戏,这样才能验证是否有有效的签名。

...
namespace tic_tac_toe {
    ...
    struct restart {
        account_name   challenger;
        account_name   host;
        account_name   by;
    };
    ...
}
Close

要关闭游戏,我们需要host 账户名和 challenger 账户名来找到该游戏。

...
namespace tic_tac_toe {
    ...
    struct close {
        account_name   challenger;
        account_name   host;
    };
    ...
}
Move

要移动一步,我们需要host 账户名和 challenger 账户名来找到该游戏。 而且,我们需要指定是谁走的这一步以及这一步走在哪。

...
namespace tic_tac_toe {
    ...
    struct movement {
        uint32_t    row;
        uint32_t    column;
    };

    struct Move {
        account_name   challenger;
        account_name   host;
        account_name   by; // the account who wants to make the move
        movement       m;
    };
    ...
}

您可以在 这里 找到atic_tac_toe.hpp 的最终代码。

主程序

打开tic_tac_toe.cpp并配置骨架代码

#include <tic_tac_toe.hpp>
using namespace eosio;
/**
*  The init() and apply() methods must have C calling convention so that the blockchain can lookup and
*  call these methods.
*/
extern "C" {

  // Only called once
  void init()  {
  }

  /// The apply method implements the dispatch of events to this contract
  void apply( uint64_t code, uint64_t action_name ) {
      // Put your message handler here
  }

} // extern "C"

Message 处理器

我们希望tic_tac_toe合约仅响应发给tic.tac.toe账户的message并且根据不同的action类型来给出不同响应。让我们在apply函数中加入message过滤器。

  ...
  void apply( uint64_t code, uint64_t action_name ) {
        if (code == N(tic.tac.toe)) {
            if (action_name == N(create)) {
                tic_tac_toe::apply_create(current_message<tic_tac_toe::create>());
            } else if (action_name == N(restart)) {
                tic_tac_toe::apply_restart(current_message<tic_tac_toe::restart>());
            } else if (action_name == N(close)) {
                tic_tac_toe::apply_close(current_message<tic_tac_toe::close>());
            } else if (action_name == N(move)) {
                tic_tac_toe::apply_move(current_message<tic_tac_toe::move>());
            }
        }
  }
  ...

注意我们在把message传入特定处理器之前使用了current_message<T>(),它是将收到的message 转为struct T的。

NB: 如果您正部署到另一个账户,请用您的账户名替换tic.tac.toe

为了简洁起见,我们把message处理器包装在namespace tic_tac_toe中:

namespace tic_tac_toe {

  void apply_create(const create& c) {
    // Put code for create action here
  }

  void apply_restart(const restart& r) {
    // Put code for restart action here
  }

  void apply_close(const close& c) {
    // Put code for close action here
  }

  void apply_move(const move& m) {
    // Put code for move action here
  }
  ...
}

create Message 处理器

对于create message的处理器,我们需要

  1. 确保message有host的签名
  2. 确保同一个玩家并不在玩这盘游戏
  3. 确保该游戏不存在
  4. 把新建的游戏存入数据库
namespace tic_tac_toe {
    ...
    void apply_create(const create& c) {
        require_auth(c.host);
        assert(c.challenger != c.host, "challenger shouldn't be the same as host");

        // Check if game already exists
        game existing_game;
        bool game_exists = Games::get(c.challenger, existing_game, c.host);
        assert(game_exists == false, "game already exists");

        game game_to_create(c.challenger, c.host);
        Games::store(game_to_create, c.host);
    }
    ...
}

Restart Message 处理器

对于 restart message 处理器,我们需要:

  1. 确保message有host或challenger的签名
  2. 确保该游戏存在
  3. 确保重启的action是host或challenger做出的
  4. 重启游戏
  5. 将更新过的游戏存入数据库
namespace tic_tac_toe {
    ...
    void apply_restart(const restart& r) {
        require_auth(r.by);

        // Check if game exists
        game game_to_restart;
        bool game_exists = Games::get(r.challenger, game_to_restart, r.host);
        assert(game_exists == true, "game doesn't exist!");

        // Check if this game belongs to the message sender
        assert(r.by == game_to_restart.host || r.by == game_to_restart.challenger, "this is not your game!");

        // Reset game
        game_to_restart.reset_game();

        Games::update(game_to_restart, game_to_restart.host);
    }
    ...
}

Close Message 处理器

对于close message 处理器,我们需要:

  1. 确保message有host的签名
  2. 确保该游戏存在
  3. 将该游戏从数据库移除
namespace tic_tac_toe {
    ...
    void apply_close(const close& c) {
        require_auth(c.host);

        // Check if game exists
        game game_to_close;
        bool game_exists = Games::get(c.challenger, game_to_close, c.host);
        assert(game_exists == true, "game doesn't exist!");

        Games::remove(game_to_close, game_to_close.host);
    }
    ...
}

Move Message处理器

对于move message处理器,我们需要:

  1. 确保message有host或challenger的签名
  2. 确保该游戏存在
  3. 确保该游戏并未结束
  4. 确保move的action是host或challenger做出的
  5. 确保轮到了正确的玩家行动
  6. 验证这一步是有效的
  7. 用这一步升级游戏板
  8. 将move_turn分给另一个玩家
  9. 判断赢家
  10. 把更新过的数据存入数据库
namespace tic_tac_toe {
    ...
    bool is_valid_movement(const movement& mvt, const game& game_for_movement) {
    // Put code here
    }

    account_name get_winner(const game& current_game) {
        // Put code here
    }

    void apply_move(const move& m) {
        require_auth(m.by);

        // Check if game exists
        game game_to_move;
        bool game_exists = Games::get(m.challenger, game_to_move, m.host);
        assert(game_exists == true, "game doesn't exist!");

        // Check if this game hasn't ended yet
        assert(game_to_move.winner == N(none), "the game has ended!");
        // Check if this game belongs to the message sender
        assert(m.by == game_to_move.host || m.by == game_to_move.challenger, "this is not your game!");
        // Check if this is the  message sender's turn
        assert(m.by == game_to_move.turn, "it's not your turn yet!");

        // Check if user makes a valid movement
        assert(is_valid_movement(m.mvt, game_to_move), "not a valid movement!");

        // Fill the cell, 1 for host, 2 for challenger
        bool is_movement_by_host = m.by == game_to_move.host;
        if (is_movement_by_host) {
        game_to_move.board[m.mvt.row * 3 + m.mvt.column] = 1;
        game_to_move.turn = game_to_move.challenger;
        } else {
        game_to_move.board[m.mvt.row * 3 + m.mvt.column] = 2;
        game_to_move.turn = game_to_move.host;
        }
        // Update winner
        game_to_move.winner = get_winner(game_to_move);
        Games::update(game_to_move, game_to_move.host);
    }
    ...
}

验证操作

验证游戏的操作意思是每一步都需要落在游戏板上的一个空格子里:

namespace tic_tac_toe {
    ...
        bool is_empty_cell(const uint8_t& cell) {
            return cell == 0;
        }
        bool is_valid_movement(const movement& mvt, const game& game_for_movement) {
            uint32_t movement_location = mvt.row * 3 + mvt.column;
            bool is_valid = movement_location < game_for_movement.board_len && is_empty_cell(game_for_movement.board[movement_location]);
            return is_valid;
        }
    ...
}

判断赢家

第一个把自己的三个标记在横向,纵向或对角线连线的玩家获胜。

namespace tic_tac_toe {
    ...
    account_name get_winner(const game& current_game) {
        if((current_game.board[0] == current_game.board[4] && current_game.board[4] == current_game.board[8]) ||
        (current_game.board[1] == current_game.board[4] && current_game.board[4] == current_game.board[7]) ||
        (current_game.board[2] == current_game.board[4] && current_game.board[4] == current_game.board[6]) ||
        (current_game.board[3] == current_game.board[4] && current_game.board[4] == current_game.board[5])) {
            //  - | - | x    x | - | -    - | - | -    - | x | -
            //  - | x | -    - | x | -    x | x | x    - | x | -
            //  x | - | -    - | - | x    - | - | -    - | x | -
            if (current_game.board[4] == 1) {
                return current_game.host;
            } else if (current_game.board[4] == 2) {
                return current_game.challenger;
            }
        } else if ((current_game.board[0] == current_game.board[1] && current_game.board[1] == current_game.board[2]) ||
                (current_game.board[0] == current_game.board[3] && current_game.board[3] == current_game.board[6])) {
            //  x | x | x       x | - | -
            //  - | - | -       x | - | -
            //  - | - | -       x | - | -
            if (current_game.board[0] == 1) {
                return current_game.host;
            } else if (current_game.board[0] == 2) {
                return current_game.challenger;
            }
        } else if ((current_game.board[2] == current_game.board[5] && current_game.board[5] == current_game.board[8]) ||
                (current_game.board[6] == current_game.board[7] && current_game.board[7] == current_game.board[8])) {
            //  - | - | -       - | - | x
            //  - | - | -       - | - | x
            //  x | x | x       - | - | x
            if (current_game.board[8] == 1) {
                return current_game.host;
            } else if (current_game.board[8] == 2) {
                return current_game.challenger;
            }
        } else {
            bool is_board_full = true;
            for (uint8_t i = 0; i < current_game.board_len; i++) {
                if (is_empty_cell(current_game.board[i])) {
                    is_board_full = false;
                    break;
                }
            }
            if (is_board_full) {
                return N(draw);
            }
        }
        return N(none);
    }
    ...
}

您可以在 这里 找到tic_tac_toe.cpp的完整代码

创建 ABI

有了Abi (即 Application Binary Interface),合约才能理解您所发的二进制信息。打开tic_tac_toe.abi并定义如下框架代码:

{
  "structs": [{
      "name": "...",
      "base": "...",
      "fields": { ... }
  }, ...],
  "actions": [{
      "action_name": "...",
      "type": "..."
  }, ...],
  "tables": [{
      "table_name": "...",
      "type": "...",
      "key_names" : [...],
      "key_types" : [...]
  }, ...]

表 ABI

在tic_tac_toe.hpp中,我们创造了一个叫game的single index i64的表。它保存了game 结构并使用challenger作为key(数据类型是account_name)。因此,abi文件是:

{
    ...
    "structs": [{
      "name": "game",
      "base": "",
      "fields": {
        "challenger": "account_name",
        "host": "account_name",
        "turn": "account_name",
        "winner": "account_name",
        "board": "uint8[]"
      }
    }],
    "tables": [{
            "table_name": "games",
            "type": "game",
            "index_type": "i64",
            "key_names" : ["challenger"],
            "key_types" : ["account_name"]
        }
    ]
    ...
}

Actions ABI

对actions来说,我们在actions里定义actions,在structs定义actions的数据结构。

{
    ...
    "structs": [{
      "name": "create",
      "base": "",
      "fields": {
        "challenger": "account_name",
        "host": "account_name"
      }
    },{
      "name": "restart",
      "base": "",
      "fields": {
        "challenger": "account_name",
        "host": "account_name",
        "by": "account_name"
      }
    },{
      "name": "close",
      "base": "",
      "fields": {
        "challenger": "account_name",
        "host": "account_name"
      }
    },{
      "name": "movement",
      "base": "",
      "fields": {
        "row": "uint32",
        "column": "uint32"
      }
    },{
      "name": "move",
      "base": "",
      "fields": {
        "challenger": "account_name",
        "host": "account_name",
        "by": "account_name",
        "movement": "movement"
      }
    }],
  "actions": [{
      "action_name": "create",
      "type": "create"
    },{
      "action_name": "restart",
      "type": "restart"
    },{
      "action_name": "close",
      "type": "close"
    },{
      "action_name": "move",
      "type": "move"
    }
  ]
    ...
}

部署!

现在所有文件(tic_tac_toe.hpp, tic_tac_toe.cpp, tic_tac_toe.abi)都完成了。可以部署了!

$ eosc set contract tic.tac.toe tic_tac_toe.wast tic_tac_toe.abi

注意您的钱包需要是解锁的,而tic.tac.toe密钥已导入。如果您要把该合约上传到其他账户,请用您的账户名替换tic.tac.toe并且确保您的钱包里有改账户的密钥。

开玩!

部署并且 transaction确认后,合约就在您的区块链上生效了。您现在就可以玩了。

新建

$ eosc push message tic.tac.toe create '{"challenger":"inita", "host":"initb"}' -S initb -S tic.tac.toe -p initb@active 

移动

$ eosc push message tic.tac.toe move '{"challenger":"inita", "host":"initb", "by":"initb", "movement":{"row":0, "column":0} }' -S initb -S tic.tac.toe -p initb@active 
$ eosc push message tic.tac.toe move '{"challenger":"inita", "host":"initb", "by":"inita", "movement":{"row":1, "column":1} }' -S initb -S tic.tac.toe -p inita@active 

重启

$ eosc push message tic.tac.toe restart '{"challenger":"inita", "host":"initb", "by":"initb"}' -S initb -S tic.tac.toe -p initb@active 

关闭

$ eosc push message tic.tac.toe close '{"challenger":"inita", "host":"initb"}' -S initb -S tic.tac.toe -p initb@active

查看游戏状态

$ eosc get table initb tic.tac.toe games
{
  "rows": [{
      "challenger": "inita",
      "host": "initb",
      "turn": "inita",
      "winner": "none",
      "board": [
        1,
        0,
        0,
        0,
        2,
        0,
        0,
        0,
        0
      ]
    }
  ],
  "more": false
}
上一篇下一篇

猜你喜欢

热点阅读