3-从零开发EOS区块链小游戏系列 - 游戏公平性及安全性

目录

  • 1-从零开发EOS区块链小游戏系列 - 使用EOS Studio
  • 2-从零开发EOS区块链小游戏系列 - 智能合约设计与实现
  • 3-从零开发EOS区块链小游戏系列 - 游戏公平性及安全性
  • 4-从零开发EOS区块链小游戏系列 - 加入Token体系
  • 5-从零开发EOS区块链小游戏系列 - 实现玩家免CPU玩游戏(终)

 上一章我们已经编写完游戏的核心功能,如果完善一下前端页面,就可以跑起来,但最后抛出了一个关于安全性方面的问题。任何系统安全性都是其命门,区块链也不例外。由于51%算力,多数都是合约攻击,以太坊比较有名的是The DAO事件,直接导致分叉,产生了一条新链ETC。EOS大大小小的攻击更多了。
 EOS的攻击中,常见的有假通知攻击、回滚攻击、随机数攻击。因为我们的游戏还没有设计转账,所以前两个先忽略,转账的话后面章节也会涉及。上一章的流程中,玩家可以重复使用同一哈希进行对战,使自己一直赢。其实这也可以说是随机数攻击的一种,因为游戏智能合约判断是根据固定公式算出随机数,而玩家提前算出了对自己有利的随机数进行攻击。这种情况的解决办法一般是在生成随机数的过程中加入一些玩家不知道的参数,称为种子,即玩家和开发者各提供一个种子来生成随机数,这样任何一方都不能提前算出随机数。但又有个问题,谁去把二者的种子提交到合约?提交者(在我们的项目里就是玩家)必然会知道另一方的种子,这样就不安全了。
 其实提交者不一定需要知道另一方的种子的值是什么,只要保证另一方在开奖前就给出了种子,并且确保给出的种子和实际智能合约使用的是同一个,就可以保证另一方没有作弊。而哈希算法正好能解决我们的问题,下面给出我们设想的一种调用流程图:


3-1 双方提供种子计算随机数

开始前需要搭建一个服务端,提供玩家获取开发者的种子

  1. 玩家向服务端获取种子,返回参数有3个,seed_hash、expire_timestamp、signature
    • seed_hash:种子(随机字符串)的哈希值:hash(seed)
    • expire_timestamp:种子的到期时间
    • signature:使用合约私钥签名:signature(seed_hash+expire_timestamp)
  2. 玩家自己生成一个种子(一般生成随机的字符串)
  3. 玩家将服务器种子哈希、时间戳、玩家种子以及签名数据一起提交到智能合约
    • 在智能合约里对种子哈希进行验签
    • 在智能合约里校验时间戳是否过期
  4. 服务器监控合约,如果有新的提交请求,就调用对战action,并将本局游戏id、服务器种子(seed)提交到智能合约
    • 在智能合约里,计算种子哈希值,即hash(seed),保证hash(seed)和前面玩家传入的哈希一致

 这种方案优点是目前来说是安全性比较高的,也是官方推荐的方案,而缺点是使得对战操作并非原子性,且流程过于复杂,我们是否可以简化一下流程呢?
 仔细想想核心的两点公平性和安全性,只要保证不破坏这两点的前提下尽可能简化就可以了:双方提供种子是保证这两点的核心逻辑,所以不能动,但我们可以从开发者种子这里入手,如果说开发者的种子不能随便生成而是根据一定规律创建,种子在开完奖或公布,任何人都可以验证,是否也是公平的?下面给出流程图:


3-2 第二种简化方案流程图
  1. 玩家自己生成一个种子(一般生成随机的字符串),然后提交到服务端
  2. 服务端生成自己的种子:
    • counter:当前玩家游戏局数,一个自增的计数器
    • sign(counter):使用开发者私钥进行签名
    • 将玩家种子和上一步算出的sign(counter)一起提交到合约

 这种方案简化了流程,但缺点是有局限性,不适用于需要玩家亲自签名的操作,比如涉及token转账的操作。
 因为我们后面也涉及token操作,所以还是选择第一种方案。
 首先我新增一张游戏表,用来保存游戏交互过程的一些参数:

    //games
    //scope is self 
     TABLE games {
      uint64_t game_id;
      name player_account;
      string user_seed;
      checksum256 house_seed_hash;
      uint64_t expire_timestamp;
      asset coin;
      signature sig;
      uint8_t status; // 1:等待处理;2:已处理;
      uint64_t created_at;
      
      uint64_t primary_key() const { return game_id;}
      uint64_t get_hsh() const { return uint64_hash(house_seed_hash);}
    };

      using game_index = multi_index<"games"_n, games,
      indexed_by<"byhsh"_n, const_mem_fun>
      >;

需要注意的是,这里定义一个索引,字段是house_seed_hash

ACTION kingofighter::newgame(const name player,
                const string& user_seed,
                const checksum256& house_seed_hash,
                const uint64_t& expire_timestamp,
                const signature& sig){
    require_auth(player);

    game_index g_tb(get_self(), get_self().value);

    //1. 签名前的数据,格式:服务端种子哈希+时间戳
    string sig_ori_data = sha256_to_hex(house_seed_hash);
    sig_ori_data += uint64_string(expire_timestamp);
    
    //2. 校验house_seed_hash
    //防止重复提交相同的数据,判断house_seed_hash是否重复
    auto hsh_idx = g_tb.get_index<"byhsh"_n>();
    auto l_hsh_itr = hsh_idx.lower_bound(uint64_hash(house_seed_hash));
    bool hsh_exist = l_hsh_itr !=hsh_idx.end() && l_hsh_itr->house_seed_hash == house_seed_hash;
    check(!hsh_exist,"house seed hash duplicate");

    //3. 验签
    //声明服务端签名合约公钥,用于验签
    const public_key pub_key = str_to_pub("EOS7ikmSFnJ4UuAuGDPQMTZFBQa7Kh6QTzBAUivksFETmX6ncxGW7");
    const char *data_cstr = sig_ori_data.c_str();
    checksum256 digest = eosio::sha256(data_cstr, strlen(data_cstr));
    //必须是pub_key对应的私钥签名
    //如果不是,直接抛出异常
    eosio::assert_recover_key(digest,sig,pub_key);

    //4. 验签名的时间戳是否已过期
    const uint32_t NOW_TS = current_time_point().sec_since_epoch();
    check(expire_timestamp > NOW_TS, "house seed hash expired");

    //5. 保存数据
    g_tb.emplace(get_self(), [&](auto &r) {
        r.game_id = g_tb.available_primary_key();
        r.player_account = player;
        r.user_seed = user_seed;
        r.house_seed_hash = house_seed_hash;
        r.expire_timestamp = expire_timestamp;
        r.coin = asset(0, symbol(symbol_code("SJ"), 4));
        r.sig = sig;
        r.created_at = NOW_TS;
    });
}

 以上是action的代码(对应图3-1第三步),从注释可以看到分为5步,这里主要讲下第2、3步:

  • 第2步是要保证house_seed_hash不能重复使用,这里使用了上面定义的属于索引byhsh,索引类型是unit64_t,定义的时候我们将checksum256转为unit64_t了,hsh_idx.lower_bound(uint64_hash(house_seed_hash));如果有重复的值,这里第一个返回的就是需要查找的值,如果第一个返回的值不是我们需要查找的值,表示没有重复,这个就是C++中用于查找的函数。
  • 第3步主要是对sig进行验证,检查是否使用指定的密钥进行签名。首先使用str_to_pub将签名公钥由字符串类型转换为public_key类型,str_to_pub代码写在utils.hpp,然后对第1步组装的数据进行验签。
  //eosiolib/crypto.hpp
 //public_key数据结构
   struct public_key {
      /**
       * Type of the public key, could be either K1 or R1
       * @brief Type of the public key
       */
      unsigned_int        type;

      /**
       * Bytes of the public key
       *
       * @brief Bytes of the public key
       */
      std::array data;
}

 下面我们还需要对battle进行改造

ACTION kingofighter::battle(const uint64_t& game_id,const string &house_seed) {
    //只能本合约调用
    require_auth(get_self());

    //1. 校验指定游戏id是否已被玩家提交
    game_index g_tb(get_self(), get_self().value);
    auto itr = g_tb.find(game_id);
    check(itr != g_tb.end(), "game does not exist");
    check(itr->status == 1,"invalid game status");

    //2. 校验服务端提交的hash(house_seed)是否就是玩家提交的house_seed_hash
    checksum256 house_seed_hash = itr->house_seed_hash;
    assert_sha256(house_seed.c_str(),strlen(house_seed.c_str()),house_seed_hash);

    //3. 使用house_seed和玩家提供的user_seed来生成本局对战的随机数
    //   格式:hash(house_seed + user_seed)
    //   最终是一个32位的uint8数组
    string seed_str = house_seed + itr->user_seed;
    const char *data_cstr = seed_str.c_str();
    checksum256 seed_hash = eosio::sha256(data_cstr, strlen(data_cstr));

    //4. 重置本局游戏的状态
     g_tb.modify(itr, _self, [&](auto &m) {
        m.status = 2; //已处理
    });
    
    //召唤玩家的英雄
    const name player = itr->player_account;
    player_index player_tb(get_self(), get_self().value);
    auto p_itr = player_tb.find(player.value);
    hero_index hero_tb(get_self(), player.value);
    const auto hero = hero_tb.begin();

    const uint32_t NOW_TS = current_time_point().sec_since_epoch();
    const uint32_t BOSS_MIN_ATK = 50;
    const uint32_t BOSS_MAX_ATK = 70;
    const uint32_t BOSS_HP = 700;
    uint32_t hero_hp = hero->hp;
    uint32_t boss_hp = BOSS_HP;

    vector  scoreboards;
    for (size_t i = 0; i < 32; i++) {
        const uint32_t hash_val = (uint32_t) seed_hash.extract_as_byte_array()[i] + NOW_TS;
        uint32_t damage;
        if (i & 1) {
            //i为奇数,BOSS攻击
            damage = hash_val % (BOSS_MAX_ATK - BOSS_MIN_ATK + 1) + BOSS_MIN_ATK;
            hero_hp = hero_hp > damage ? hero_hp - damage : 0;
        } else {
            //i为偶数,玩家攻击
            damage = hash_val % (hero->max_atk - hero->min_atk + 1) + hero->min_atk;
            //是否暴击,暴击概率25%
            if (hash_val % 4 == 0)
                damage += 100;
            boss_hp = boss_hp > damage ? boss_hp - damage : 0;
        }

        //这一轮的战斗结果
        scoreboard sb_item = {
                .round_no = i + 1,
                .attacker = i & 1 ? get_self() : player,
                .defender = i & 1 ? player : get_self(),
                .damage = damage,
                .defender_hp = i & 1 ? hero_hp : boss_hp
        };
        scoreboards.emplace_back(sb_item);
        //如果任何一方血量归0,战斗结束
        if (hero_hp == 0 || boss_hp == 0)
            break;
    }

    //修改玩家数据
    player_tb.modify(p_itr, _self, [&](auto &m) {
        m.counter += 1;
        //如果玩家输了,需要掉落100个水晶
        if (hero_hp == 0) {
            if (m.coin.amount <= 100 * 10000)
                m.coin = asset(0, symbol(symbol_code("SJ"), 4));
            else
                m.coin -= asset(100 * 10000, symbol(symbol_code("SJ"), 4));
        }
    });

    //记录下本次战斗的结果
    game_record_index gr_tb(get_self(), get_self().value);
    gr_tb.emplace(get_self(), [&](auto &r) {
        r.game_id = gr_tb.available_primary_key();
        r.player_account = player;
        r.player_counter = p_itr->counter;
        r.scoreboards = scoreboards;
        r.game_result = hero_hp > 0 ? "win" : "lose";
        r.created_at = NOW_TS;
    });

    //如果玩家赢了 随机奖励一个宝箱(金、银、铜)
    if (hero_hp > 0) {
        box_index box_tb(get_self(), get_self().value);
        box_tb.emplace(get_self(), [&](auto &r) {
            r.id = box_tb.available_primary_key();
            r.player_account = player;
            r.level = seed_hash.extract_as_byte_array()[31] % (uint8_t) 3 + 1;
            r.created_at = NOW_TS;
        });
    }
}

 重点看注释标明的1-4步,后面的基本就是上一章的代码,就不重复讲了。其中最为关键的是第2步,需要校验玩家传入的house_seed_hash哈希值,和这里的house_seed要相匹配,因为这个action是服务端来调用,所以其实这里的校验是避免服务端作弊,是相对重要的一步。其他代码看注释就可以了,都有说明。
 下面开始编写服务端代码,我使用python3.x,轻量级方便快捷还好用,项目的目录结构如下:

服务端目录结构

 其中eospy是一个三方的区块链操作工具,目录里面有用到redis缓存、因为需要对外提供接口,使用一个微型的web框架flask,还有一些启动和停止程序的脚本。主要eos_service.py和kof.py两个业务相关文件就可以了。
 首先玩家获取种子信息的接口再views.py

import app.service.kof as KOF
...
@app.route('/eos/get_seed', methods=['GET'])
def get_seed():
    data = KOF.get_seed()
    result = make_response(json.dumps(data, ensure_ascii=False))
    return result

 调用了kof.py的get_seed方法:

def get_seed():
    """
    提供给客户端玩家的种子相关数据
    :return: 包含服务端种子哈希、有效时间、签名数据
    """
    # 服务端种子;重要数据,不可泄露
    house_seed = generate_house_seed()
    # 服务端种子哈希,返回给客户端玩家
    house_seed_hash = sha256(house_seed)
    # 本次签名数据过期时间,返回给客户端玩家
    expire_timestamp = get_expire_timestamp()
    # 本次签名数据,格式:服务端种子哈希+过期时间戳
    sig_data = house_seed_hash + str(expire_timestamp)
    digest = sha256(sig_data)
    sig = EOS.sign(digest)

    for_client_m = {"house_seed_hash": house_seed_hash,
                    "expire_timestamp": expire_timestamp,
                    "sig": sig}
    for_server_m = {"house_seed": house_seed,
                    "house_seed_hash": house_seed_hash,
                    "expire_timestamp": expire_timestamp,
                    "sig": sig}
    REDIS.set(house_seed_hash, for_server_m)
    return for_client_m

 以上,就是客户端玩家获取种子信息接口的代码,get_seed内部调用的方法这里就不贴了。接下来看看服务端如何调用区块链智能合约:

if __name__ == '__main__':
    scheduler.add_job(func=KOF.battle_timer, id="battle", args=(),
                      run_date=datetime.datetime.now() + datetime.timedelta(seconds=2))
    app.run(host="0.0.0.0", port=8080, debug=False)
def battle_timer():
    """
    轮询合约的games表,通过索引找出需要处理的游戏(status==1)
    以house_seed_hash作为缓存key,从缓存中取出需要处理的游戏数据
    通过本地安装的cleos客户工具,调用智能合约,进行对战操作
    执行成功后可删除本地缓存
    """
    args = EOS.index_table(table='games', lower_bound=1, limit=100, index_position=3)
    while True:
        rows = EOS.query_table_RPC(args)
        if len(rows) == 0:
            # 没有任何需要处理的数据
            continue

        for r in rows:
            if r['status'] == 1:
                cache_key = r['house_seed_hash']
                cache_obj = REDIS.get(cache_key)
                if cache_obj is not None:
                    # 从缓存中取出游戏数据
                    game = eval(cache_obj)
                    # 调用智能合约
                    exec_r = EOS.exec_battle_cmd(game_id=r['game_id'], house_seed=game['house_seed'])
                    if exec_r is True:
                        # 如果执行成功,清除缓存
                        REDIS.remove(cache_key)

        time.sleep(2)

 第一段代码是在程序启动的时候,利用scheduler调度框架去开启一个任务,这个任务主要轮询区块链上游戏表,通过表索引筛选需要处理的数据。
EOS.index_table(table='games', lower_bound=1, limit=100, index_position=3)实现查询需要处理的数据,还记得上面我们games表status字段值有1和2两个值分别代表未处理和已处理。这里lower_bound=1就是查询未处理。limit即查询条数。index_position是索引的位置,为什么是3?我们看看前面索引是怎么声明的:

   using game_index = multi_index<"games"_n, games,
   indexed_by<"byhsh"_n, const_mem_fun>,
   indexed_by<"bystatus"_n, const_mem_fun>
    >;

 主键占了第一个位置,get_hsh占了第二个位置,get_status就是第三个位置。
 下面的主要的代码都在EOS命名空间其实就是eos_service.py文件:

import json
import subprocess
from subprocess import PIPE
from app.eospy.keys import EOSKey

import urllib3

http = urllib3.PoolManager()

# 使用麒麟测试链
EOS_HOST = 'https://api-kylin.eosasia.one'
# 我们的智能合约账号
CONTRACT = 'kingofighter'
# 构建EOS key
k = EOSKey('你的智能合约的私钥地址')


def sign(digest):
    """
    使用指定私钥进行签名
    :param digest: 需要签名的数据
    :return: 签名后数据
    """
    return k.sign(digest)


def index_table(table, lower_bound, limit, index_position):
    """
    构建一个索引查找模式的参数
    :param table: 合约的表名
    :param lower_bound: 查找的起始值
    :param limit: 查找数量
    :param index_position: 索引位置,主键为1
    :return: 索引查找的参数
    """
    return base(CONTRACT, table, CONTRACT, lower_bound, limit, index_position)


def base(contract, table, scope, lower_bound, limit, index_position):
    if index_position == 1:
        return {'code': contract,
                'table': table,
                'json': 'true',
                'limit': limit,
                'lower_bound': lower_bound,
                'scope': scope}
    else:
        return {'code': contract,
                'table': table,
                'json': 'true',
                'limit': limit,
                'lower_bound': lower_bound,
                'scope': scope,
                'key_type': 'i64',
                'index_position': index_position}


def query_table_RPC(args):
    """
    使用RPC方式查询表
    :param args: 查询入参
    :return: 查询结果
    """
    encode_data = json.dumps(args).encode('utf-8')
    r = http.request('POST', EOS_HOST + '/v1/chain/get_table_rows', body=encode_data)
    data = json.loads(r.data.decode('utf-8'))
    return data['rows']


def exec_battle_cmd(game_id, house_seed):
    """
    通过本地安装的cleos客户工具,调用智能合约,进行对战操作
    :param game_id: 游戏id
    :param house_seed: 服务端种子
    :return: 是否执行成功
    """
    cmd = "cleos -u " + EOS_HOST + " push action " + CONTRACT + " battle '[" + str(
        game_id) + ",\"" + house_seed + "\"]' -p " + CONTRACT
    p = subprocess.Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE, shell=True)
    stdout, stderr = p.communicate()
    exit_code = p.returncode
    return exit_code == 0

  调用合约的方式你也可以使用一些python的三方库,我这里是直接命令行操作cleos客户端,前提是需要安装好CLEOS工具。这个工具安装也很方便,推荐使用这种方法,下面我们跑起来看下效果:
 首先启动服务端:

cd kof-server
sh start.sh
ppending output to nohup.out

 接着客户端玩家调用接口获取种子

curl  http://127.0.0.1:8080/eos/get_seed
{
    "house_seed_hash":"24f82aa823bd87a712dd159f416c79dd7e6bf7b255e04f9cc3280a80c8c083b8",
    "expire_timestamp":1578390077,
    "sig":"SIG_K1_Kawhgbf5XDQnTuW87AM4iCbNmqHAKiZfTD6h5Zn7Tz5qpwKrBqMhiCrJe5V9xxpgUSQMeJ6xe9fCNMkBNgu7eLy8wGah2E"
}

 接着客户端需要生成一个12字符长度的种子,这里随便写一个12qwaszxerdf,然后打开EOS studio,启动麒麟测试节点,填写从服务端获取的信息,调用合约:

客户端调用智能合约

 从1-6分别是,1:玩家EOS账号地址;2:用户的随机种子;3:服务端种子哈希值;4:本次签名过期时间;5:服务端签名数据;6:执行后保存在games表的数据,此时数据的status应该为1,因为我们后端服务有轮询处理,过一会就会变成2,表示已处理:
本局游戏已被处理

本局游戏对战的详细信息

 到这里整个流程已经走完。正常情况是应该使用HTML提供给玩家调用,然后展示对战过程和对战结果,我为了方便就直接使用EOS studio来代替了。从上图可以看出,对战的结果是玩家输了,这个游戏可能是太难了,不过没关系,还记得我们对战胜利会获得的水晶(SJ)吗?其实这里水晶会被设计成一种代币(TOKEN),下一章我们加入TOKEN体系来实现“氪金”的功能。

本章节源代码地址:
python服务端:https://github.com/jan-gogogo/kof-server
智能合约:https://github.com/jan-gogogo/kof-chapter3

你可能感兴趣的:(3-从零开发EOS区块链小游戏系列 - 游戏公平性及安全性)