目錄
- 1-從零開發(fā)EOS區(qū)塊鏈小游戲系列 - 使用EOS Studio
- 2-從零開發(fā)EOS區(qū)塊鏈小游戲系列 - 智能合約設(shè)計與實現(xiàn)
- 3-從零開發(fā)EOS區(qū)塊鏈小游戲系列 - 游戲公平性及安全性
- 4-從零開發(fā)EOS區(qū)塊鏈小游戲系列 - 加入Token體系
- 5-從零開發(fā)EOS區(qū)塊鏈小游戲系列 - 實現(xiàn)玩家免CPU玩游戲(終)
?上一章我們已經(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)用流程圖:

開始前需要搭建一個服務(wù)端,提供玩家獲取開發(fā)者的種子
- 玩家向服務(wù)端獲取種子,返回參數(shù)有3個,seed_hash、expire_timestamp、signature
- seed_hash:種子(隨機(jī)字符串)的哈希值:hash(seed)
- expire_timestamp:種子的到期時間
- signature:使用合約私鑰簽名:signature(seed_hash+expire_timestamp)
- 玩家自己生成一個種子(一般生成隨機(jī)的字符串)
- 玩家將服務(wù)器種子哈希、時間戳、玩家種子以及簽名數(shù)據(jù)一起提交到智能合約
- 在智能合約里對種子哈希進(jìn)行驗簽
- 在智能合約里校驗時間戳是否過期
- 服務(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)建,種子在開完獎或公布,任何人都可以驗證,是否也是公平的?下面給出流程圖:

- 玩家自己生成一個種子(一般生成隨機(jī)的字符串),然后提交到服務(wù)端
- 服務(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)如下:

?其中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)用合約:

?從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,表示已處理:

?到這里整個流程已經(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