3-從零開發(fā)EOS區(qū)塊鏈小游戲系列 - 游戲公平性及安全性

目錄

?上一章我們已經(jīng)編寫完游戲的核心功能,如果完善一下前端頁面,就可以跑起來,但最后拋出了一個關(guān)于安全性方面的問題。任何系統(tǒng)安全性都是其命門,區(qū)塊鏈也不例外。由于51%算力,多數(shù)都是合約攻擊,以太坊比較有名的是The DAO事件,直接導(dǎo)致分叉,產(chǎn)生了一條新鏈ETC。EOS大大小小的攻擊更多了。
?EOS的攻擊中,常見的有假通知攻擊、回滾攻擊、隨機(jī)數(shù)攻擊。因為我們的游戲還沒有設(shè)計轉(zhuǎn)賬,所以前兩個先忽略,轉(zhuǎn)賬的話后面章節(jié)也會涉及。上一章的流程中,玩家可以重復(fù)使用同一哈希進(jìn)行對戰(zhàn),使自己一直贏。其實這也可以說是隨機(jī)數(shù)攻擊的一種,因為游戲智能合約判斷是根據(jù)固定公式算出隨機(jī)數(shù),而玩家提前算出了對自己有利的隨機(jī)數(shù)進(jìn)行攻擊。這種情況的解決辦法一般是在生成隨機(jī)數(shù)的過程中加入一些玩家不知道的參數(shù),稱為種子,即玩家和開發(fā)者各提供一個種子來生成隨機(jī)數(shù),這樣任何一方都不能提前算出隨機(jī)數(shù)。但又有個問題,誰去把二者的種子提交到合約?提交者(在我們的項目里就是玩家)必然會知道另一方的種子,這樣就不安全了。
?其實提交者不一定需要知道另一方的種子的值是什么,只要保證另一方在開獎前就給出了種子,并且確保給出的種子和實際智能合約使用的是同一個,就可以保證另一方?jīng)]有作弊。而哈希算法正好能解決我們的問題,下面給出我們設(shè)想的一種調(diào)用流程圖:


3-1 雙方提供種子計算隨機(jī)數(shù)

開始前需要搭建一個服務(wù)端,提供玩家獲取開發(fā)者的種子

  1. 玩家向服務(wù)端獲取種子,返回參數(shù)有3個,seed_hash、expire_timestamp、signature
    • seed_hash:種子(隨機(jī)字符串)的哈希值:hash(seed)
    • expire_timestamp:種子的到期時間
    • signature:使用合約私鑰簽名:signature(seed_hash+expire_timestamp)
  2. 玩家自己生成一個種子(一般生成隨機(jī)的字符串)
  3. 玩家將服務(wù)器種子哈希、時間戳、玩家種子以及簽名數(shù)據(jù)一起提交到智能合約
    • 在智能合約里對種子哈希進(jìn)行驗簽
    • 在智能合約里校驗時間戳是否過期
  4. 服務(wù)器監(jiān)控合約,如果有新的提交請求,就調(diào)用對戰(zhàn)action,并將本局游戲id、服務(wù)器種子(seed)提交到智能合約
    • 在智能合約里,計算種子哈希值,即hash(seed),保證hash(seed)和前面玩家傳入的哈希一致

?這種方案優(yōu)點是目前來說是安全性比較高的,也是官方推薦的方案,而缺點是使得對戰(zhàn)操作并非原子性,且流程過于復(fù)雜,我們是否可以簡化一下流程呢?
?仔細(xì)想想核心的兩點公平性和安全性,只要保證不破壞這兩點的前提下盡可能簡化就可以了:雙方提供種子是保證這兩點的核心邏輯,所以不能動,但我們可以從開發(fā)者種子這里入手,如果說開發(fā)者的種子不能隨便生成而是根據(jù)一定規(guī)律創(chuàng)建,種子在開完獎或公布,任何人都可以驗證,是否也是公平的?下面給出流程圖:


3-2 第二種簡化方案流程圖
  1. 玩家自己生成一個種子(一般生成隨機(jī)的字符串),然后提交到服務(wù)端
  2. 服務(wù)端生成自己的種子:
    • counter:當(dāng)前玩家游戲局?jǐn)?shù),一個自增的計數(shù)器
    • sign(counter):使用開發(fā)者私鑰進(jìn)行簽名
    • 將玩家種子和上一步算出的sign(counter)一起提交到合約

?這種方案簡化了流程,但缺點是有局限性,不適用于需要玩家親自簽名的操作,比如涉及token轉(zhuǎn)賬的操作。
?因為我們后面也涉及token操作,所以還是選擇第一種方案。
?首先我新增一張游戲表,用來保存游戲交互過程的一些參數(shù):

    //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<games, uint64_t, 
      &games::get_hsh>>
      >;

需要注意的是,這里定義一個索引,字段是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. 簽名前的數(shù)據(jù),格式:服務(wù)端種子哈希+時間戳
    string sig_ori_data = sha256_to_hex(house_seed_hash);
    sig_ori_data += uint64_string(expire_timestamp);
    
    //2. 校驗house_seed_hash
    //防止重復(fù)提交相同的數(shù)據(jù),判斷house_seed_hash是否重復(fù)
    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. 驗簽
    //聲明服務(wù)端簽名合約公鑰,用于驗簽
    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對應(yīng)的私鑰簽名
    //如果不是,直接拋出異常
    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. 保存數(shù)據(jù)
    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的代碼(對應(yīng)圖3-1第三步),從注釋可以看到分為5步,這里主要講下第2、3步:

  • 第2步是要保證house_seed_hash不能重復(fù)使用,這里使用了上面定義的屬于索引byhsh,索引類型是unit64_t,定義的時候我們將checksum256轉(zhuǎn)為unit64_t了,hsh_idx.lower_bound(uint64_hash(house_seed_hash));如果有重復(fù)的值,這里第一個返回的就是需要查找的值,如果第一個返回的值不是我們需要查找的值,表示沒有重復(fù),這個就是C++中用于查找的函數(shù)。
  • 第3步主要是對sig進(jìn)行驗證,檢查是否使用指定的密鑰進(jìn)行簽名。首先使用str_to_pub將簽名公鑰由字符串類型轉(zhuǎn)換為public_key類型,str_to_pub代碼寫在utils.hpp,然后對第1步組裝的數(shù)據(jù)進(jìn)行驗簽。
  //eosiolib/crypto.hpp
 //public_key數(shù)據(jù)結(jié)構(gòu)
   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<char,33> data;
}

?下面我們還需要對battle進(jìn)行改造

ACTION kingofighter::battle(const uint64_t& game_id,const string &house_seed) {
    //只能本合約調(diào)用
    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. 校驗服務(wù)端提交的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來生成本局對戰(zhàn)的隨機(jī)數(shù)
    //   格式:hash(house_seed + user_seed)
    //   最終是一個32位的uint8數(shù)組
    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. 重置本局游戲的狀態(tài)
     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 <scoreboard> 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為奇數(shù),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為偶數(shù),玩家攻擊
            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;
        }

        //這一輪的戰(zhàn)斗結(jié)果
        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,戰(zhàn)斗結(jié)束
        if (hero_hp == 0 || boss_hp == 0)
            break;
    }

    //修改玩家數(shù)據(jù)
    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));
        }
    });

    //記錄下本次戰(zhàn)斗的結(jié)果
    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;
    });

    //如果玩家贏了 隨機(jī)獎勵一個寶箱(金、銀、銅)
    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;
        });
    }
}

?重點看注釋標(biāo)明的1-4步,后面的基本就是上一章的代碼,就不重復(fù)講了。其中最為關(guān)鍵的是第2步,需要校驗玩家傳入的house_seed_hash哈希值,和這里的house_seed要相匹配,因為這個action是服務(wù)端來調(diào)用,所以其實這里的校驗是避免服務(wù)端作弊,是相對重要的一步。其他代碼看注釋就可以了,都有說明。
?下面開始編寫服務(wù)端代碼,我使用python3.x,輕量級方便快捷還好用,項目的目錄結(jié)構(gòu)如下:

服務(wù)端目錄結(jié)構(gòu)

?其中eospy是一個三方的區(qū)塊鏈操作工具,目錄里面有用到redis緩存、因為需要對外提供接口,使用一個微型的web框架flask,還有一些啟動和停止程序的腳本。主要eos_service.py和kof.py兩個業(yè)務(wù)相關(guān)文件就可以了。
?首先玩家獲取種子信息的接口再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

?調(diào)用了kof.py的get_seed方法:

def get_seed():
    """
    提供給客戶端玩家的種子相關(guān)數(shù)據(jù)
    :return: 包含服務(wù)端種子哈希、有效時間、簽名數(shù)據(jù)
    """
    # 服務(wù)端種子;重要數(shù)據(jù),不可泄露
    house_seed = generate_house_seed()
    # 服務(wù)端種子哈希,返回給客戶端玩家
    house_seed_hash = sha256(house_seed)
    # 本次簽名數(shù)據(jù)過期時間,返回給客戶端玩家
    expire_timestamp = get_expire_timestamp()
    # 本次簽名數(shù)據(jù),格式:服務(wù)端種子哈希+過期時間戳
    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內(nèi)部調(diào)用的方法這里就不貼了。接下來看看服務(wù)端如何調(diào)用區(qū)塊鏈智能合約:

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,從緩存中取出需要處理的游戲數(shù)據(jù)
    通過本地安裝的cleos客戶工具,調(diào)用智能合約,進(jìn)行對戰(zhàn)操作
    執(zhí)行成功后可刪除本地緩存
    """
    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:
            # 沒有任何需要處理的數(shù)據(jù)
            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:
                    # 從緩存中取出游戲數(shù)據(jù)
                    game = eval(cache_obj)
                    # 調(diào)用智能合約
                    exec_r = EOS.exec_battle_cmd(game_id=r['game_id'], house_seed=game['house_seed'])
                    if exec_r is True:
                        # 如果執(zhí)行成功,清除緩存
                        REDIS.remove(cache_key)

        time.sleep(2)

?第一段代碼是在程序啟動的時候,利用scheduler調(diào)度框架去開啟一個任務(wù),這個任務(wù)主要輪詢區(qū)塊鏈上游戲表,通過表索引篩選需要處理的數(shù)據(jù)。
EOS.index_table(table='games', lower_bound=1, limit=100, index_position=3)實現(xiàn)查詢需要處理的數(shù)據(jù),還記得上面我們games表status字段值有1和2兩個值分別代表未處理和已處理。這里lower_bound=1就是查詢未處理。limit即查詢條數(shù)。index_position是索引的位置,為什么是3?我們看看前面索引是怎么聲明的:

   using game_index = multi_index<"games"_n, games,
   indexed_by<"byhsh"_n, const_mem_fun<games, uint64_t, &games::get_hsh>>,
   indexed_by<"bystatus"_n, const_mem_fun<games, uint64_t, &games::get_status>>
    >;

?主鍵占了第一個位置,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'
# 構(gòu)建EOS key
k = EOSKey('你的智能合約的私鑰地址')


def sign(digest):
    """
    使用指定私鑰進(jìn)行簽名
    :param digest: 需要簽名的數(shù)據(jù)
    :return: 簽名后數(shù)據(jù)
    """
    return k.sign(digest)


def index_table(table, lower_bound, limit, index_position):
    """
    構(gòu)建一個索引查找模式的參數(shù)
    :param table: 合約的表名
    :param lower_bound: 查找的起始值
    :param limit: 查找數(shù)量
    :param index_position: 索引位置,主鍵為1
    :return: 索引查找的參數(shù)
    """
    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: 查詢?nèi)雲(yún)?    :return: 查詢結(jié)果
    """
    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客戶工具,調(diào)用智能合約,進(jìn)行對戰(zhàn)操作
    :param game_id: 游戲id
    :param house_seed: 服務(wù)端種子
    :return: 是否執(zhí)行成功
    """
    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

? 調(diào)用合約的方式你也可以使用一些python的三方庫,我這里是直接命令行操作cleos客戶端,前提是需要安裝好CLEOS工具。這個工具安裝也很方便,推薦使用這種方法,下面我們跑起來看下效果:
?首先啟動服務(wù)端:

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

?接著客戶端玩家調(diào)用接口獲取種子

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,啟動麒麟測試節(jié)點,填寫從服務(wù)端獲取的信息,調(diào)用合約:

客戶端調(diào)用智能合約

?從1-6分別是,1:玩家EOS賬號地址;2:用戶的隨機(jī)種子;3:服務(wù)端種子哈希值;4:本次簽名過期時間;5:服務(wù)端簽名數(shù)據(jù);6:執(zhí)行后保存在games表的數(shù)據(jù),此時數(shù)據(jù)的status應(yīng)該為1,因為我們后端服務(wù)有輪詢處理,過一會就會變成2,表示已處理:
本局游戲已被處理

本局游戲?qū)?zhàn)的詳細(xì)信息

?到這里整個流程已經(jīng)走完。正常情況是應(yīng)該使用HTML提供給玩家調(diào)用,然后展示對戰(zhàn)過程和對戰(zhàn)結(jié)果,我為了方便就直接使用EOS studio來代替了。從上圖可以看出,對戰(zhàn)的結(jié)果是玩家輸了,這個游戲可能是太難了,不過沒關(guān)系,還記得我們對戰(zhàn)勝利會獲得的水晶(SJ)嗎?其實這里水晶會被設(shè)計成一種代幣(TOKEN),下一章我們加入TOKEN體系來實現(xiàn)“氪金”的功能。

本章節(jié)源代碼地址:
python服務(wù)端:https://github.com/jan-gogogo/kof-server
智能合約:https://github.com/jan-gogogo/kof-chapter3

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。
禁止轉(zhuǎn)載,如需轉(zhuǎn)載請通過簡信或評論聯(lián)系作者。

友情鏈接更多精彩內(nèi)容