Solidityで作る公平なガチャ

ソーシャルゲームに見られるガチャというシステム上では個人が何十万円もつぎ込むこともあるように非常に高価なお金が動きます。ガチャは確率でしか欲しいアイテムが手に入らないため、運営が不正をしてこの確率を偽っていないことを証明することは非常に重要です。もしブロックチェーン上でガチャを実装できれば運営が恣意的な操作を行う余地がない信頼のおける公平なシステムを作ることが出来るでしょう。

qiita.com

Qiitaに投稿されたこちらの記事ではブロックチェーン上でガチャを実装する方法が紹介されています。今回はこれに従って実際にSolidityで動作するガチャを作ってみることにしましょう。

余談ですが記事中で解説されている コミットメントによる公平なガチャ はまさに Satoshi Dice で採用されているガチャの方式ですね。

Decentralized Gacha

まずはコメントにて解説されているブロックチェーンによる公平なガチャの実現手順を運営とユーザーの二人を仮定して簡単に説明します。

  1. ユーザーは乱数 rb を生成する
  2. ユーザーは次のようなコントラクトを実行する。ただしこのコントラクトの引数には乱数 rb が含まれている
    1. コントラクトでは一意な番号 n が生成される
    2. (n,rb)ブロックチェーンに書き込まれる
  3. 運営はブロックチェーンから n を読み取り、n に対応する乱数 ra を生成する。
  4. 運営は次のようなコントラクトを実行する。ただしこのコントラクトの引数には乱数 ra が含まれている
    1. このときのブロックチェーンの高さを i として、組 (ra,i)ブロックチェーンに書き込む
  5. i+1 番目のブロックのハッシュ値h として、運営は α := H(ra||rb||n||B||h) を計算し、それに対応する景品をユーザーに与える。ただし、このときの値と景品の対応をユーザーは事前に知っているものとする
  6. ユーザーは α := H(ra||rb||n||B||h) を検証する

ただし B はユーザーのアドレス、 Hハッシュ関数|| は左右の文字列の結合を表すこととします。これをSolidityで実装すると以下のようになりました。

contract/Gacha.sol

pragma solidity ^0.4.18;

import "zeppelin-solidity/contracts/ownership/Ownable.sol";

contract Gacha is Ownable {

  // ガチャの結果を決めるために必要な変数群
  struct Capsule {
    uint ra;     // 運営が決めた乱数
    uint rb;     // ユーザーが決めた乱数
    uint count;  // 何回目のガチャか
    uint height; // 乱数として参照するブロックの高さ
  }

  // ガチャを引くイベント
  event Draw (
    address indexed _from,
    uint            _count
  );

  uint                        public gachaPrice;     // ガチャを引くのに必要な金額
  uint                        public gachaCount = 0; // ガチャが回された回数
  mapping(address => Capsule) public gachaMachine;   // ユーザーと結果を計算する変数の状態
  mapping(address => uint[])  public goods;          // ガチャの結果

  // goodsの個数を計算する
  function goodsCount() public view returns (uint) {
    return goods[msg.sender].length;
  }

  // コンストラクタ
  function Gacha(uint price) public {
    gachaPrice = price;
  }

  // ガチャを引く関数(この時点では結果は決まらない)
  function draw(uint rb) public payable {
    require (msg.value >= gachaPrice);
    require (gachaMachine[msg.sender].count == 0);

    gachaCount++;
    gachaMachine[msg.sender] = Capsule(0, rb, gachaCount, 0);

    Draw(msg.sender, gachaCount);
  }

  // ガチャを完成させるのに必要な情報を計算する関数
  function packing(address user, uint ra) public onlyOwner {
    Capsule storage capsule = gachaMachine[user];
    require(capsule.count  != 0);
    require(capsule.height == 0);

    capsule.ra = ra;
    capsule.height = block.number + 1;
    gachaMachine[user] = capsule;
  }

  // 5つのbytes32を結合する関数
  function concat(bytes32 a, bytes32 b, bytes32 c, bytes32 d, bytes32 e) private pure returns (bytes) {
    bytes memory abcde = new bytes(a.length + b.length + c.length + d.length + e.length);
    uint k = 0;
    for (uint i = 0; i < a.length; i++) abcde[k++] = a[i];
    for (     i = 0; i < b.length; i++) abcde[k++] = b[i];
    for (     i = 0; i < c.length; i++) abcde[k++] = c[i];
    for (     i = 0; i < d.length; i++) abcde[k++] = d[i];
    for (     i = 0; i < e.length; i++) abcde[k++] = e[i];
    return abcde;
  }

  // ガチャの結果を取得する関数
  function get() public {
    Capsule storage capsule = gachaMachine[msg.sender];
    require(capsule.count  != 0);
    require(capsule.height != 0);
    require(block.number >= capsule.height);

    bytes memory concatted = concat(
      bytes32(capsule.ra),
      bytes32(capsule.rb),
      bytes32(capsule.count),
      bytes32(msg.sender),
      block.blockhash(capsule.height)
      );

    uint alpha = uint(sha256(concatted));
    goods[msg.sender].push(alpha);
    delete gachaMachine[msg.sender];
  }
}

ユーザーは draw を呼び出してガチャを引き、それを受けて運営が packing を呼び出してガチャに必要な情報を完成させ、ユーザーは最後に get を呼び出して景品を手に入れるという流れになります。実装の都合上、一回ずつしか引けないという制約を加えていますがこれは本質的なものではなく適切なデータ構造を使うことで回避できるでしょう。またbytes32concatこちらのStackExchangeを参考に実装しています。draw を実行する際に Draw というイベントを記録しているので以下のようなサーバーの実装を行うことで packing の実行を自動化することができます。

server.js

const crypto = require('crypto');
const sha256 = (msg) => crypto.createHash('sha256').update(msg).digest('hex');

const Web3 = require('web3');
const web3 = new Web3(new Web3.providers.HttpProvider('http://localhost:9545/'));

const Gacha = require('./build/contracts/Gacha.json');
const contract = web3.eth.contract(Gacha.abi).at('0x345ca3e014aaf5dca488057592ee47305d9b3e10');

const address = '0x627306090abab3a6e1400e9345bc60c78a8bef57';
const salt = '3bda213fc5ecd195a546d035d2c40ce5';

// Drawイベントを監視してpackingを自動的に実行する
contract.Draw((err, event) => {
  if (err) console.log(err);
  else {
    console.log("Draw: ", Number(event.args._count), event.args._from);

    const ra = parseInt(sha256(event.args._count.toString(16) + salt), 16);
    contract.packing(event.args._from, ra, {from: address});
  }
});

※ 使用している web3.js のバージョンは 0.20.1 です

server.js を起動した状態で truffle develop から実際にガチャを引いてみましょう。

$ truffle develop
...
truffle(develop)> migrate
...
truffle(develop)> contract = Gacha.at(Gacha.address)
...
truffle(develop)> contract.draw(1, {from: web3.eth.accounts[1], value: 1e16})
...
truffle(develop)> contract.get({from: web3.eth.accounts[1]})
...
truffle(develop)> contract.goods.call(web3.eth.accounts[1], 0)
{ [String: '2.4616282283240862031915465964390757380650285675508734067987419433071774252499e+76']
  s: 1,
  e: 76,
  c:
   [ 2461628,
     22832408620319,
     15465964390757,
     38065028567550,
     87340679874194,
     33071774252499 ] }

最後に得られた 246162... (α)という数字がガチャの結果です。実際にガチャとして完成させるには例えばこの数値を100で割った余りを求めて

  • 1 より小さければ激レアアイテム
  • 20 より小さければレアアイテム
  • 50 より小さければちょっといいアイテム
  • それ以外は普通のアイテム

のように扱えばいいでしょう。ERC721と組み合わせるのも面白いかもしれません。

なにより大事なところは最終的に得られたαという数字が誰にも予測することができない 乱数になっている ということです。その理由はαの計算に、運営とユーザーにとっては未来のブロックのハッシュ値を利用していて、Proof of Workの性質からマイナーにはこの値が自由に決めれるものではないからです。ただ、マイナーはブロックのハッシュ値を計算した後にそれを採用しないという戦略がとれます。しかしガチャの文脈に限って言えば以下の考察よりマイナーはマイニング報酬を得るほうがリターンが高いため、この戦略を採用することは考えにくいと思います。

Ethereumのマイニング報酬は現在5ETHで、1ETH ≒ 10万円とすると日本円で50万円です。マイナーがもし出るはずの景品を出さないと判断するとするとその判断により50万円以上の利益が得られないといけません。例えば1回当たり n 円のガチャで x %で出ると言っている景品がこのトリックにより y %の確率で出るとすると実際は n/x 円の期待値で売ろうとしているものを n/y 円で売ることになるので期待値としてn/y − n/x 円分運営が得をすることになると思います。こちらの記事によるとEthpool/Ethermineは約25%のシェアを占めるので y=0.75x にすることができるでしょう。すると運営が得をする期待値は n/3x 円となり、 n/3x が50万円を上回るためには元の景品の期待値 n/x が150万円以上ということになります。1回のガチャの値段 n が250円だとすると約0.017%以下の出現確率ということになり、一般のソシャゲのSSRが1%ぐらいの確率で排出されていることを考えると確かにマイナーはずるをしないほうが得をしそうです(イベントの限定キャラとかになると分かりませんがw)。

引用: https://qiita.com/yyu/items/4eaa43693e39c60a8661#comment-9bdd13d63257ccb89fa8

それでもマイナーが意図的にブロックを選択してしまうという状況では残念ながらこのガチャは公平だとはいい切れません。何の仮定を置くこと無くブロックチェーン上で真にランダムな値を検証可能な形で作る方法に関しては以下の文献が参考になるでしょう。

もっとシンプルなガチャ

ところでαが予測できないことに本質的に寄与しているのは未来のブロックのハッシュ値 h でした。実は乱数を生成するだけならこの h さえあれば十分なのではないでしょうか?しかし、h だけを使うと同じブロックの中で複数回ガチャを引くと同じ結果になってしまうので、ガチャを引くごとに一意に生成される n も使って乱数を作ってみましょう。修正したガチャの手順は以下のようになります。

  1. ユーザーは次のようなコントラクトを実行する
    1. コントラクトでは一意な番号 n が生成される
    2. この時のブロックチェーンの高さを i とする
    3. (n, i)ブロックチェーンに書き込まれる
  2. i 番目のブロックのハッシュ値h として、運営は α := H(n||h) を計算し、それに対応する景品をユーザーに与える。ただし、このときの値と景品の対応をユーザーは事前に知っているものとする
  3. ユーザーは α := H(n||h) を検証する

ずいぶん簡単になりました。i 番目のブロックを生成している時はそのハッシュ値はわからないため i+1 番目のブロックまで待たずに単純に i 番目のブロックのハッシュ値を利用するようにも修正を加えています。Solidityのコードも見てみましょう。

contract/SimpleGacha.sol

pragma solidity ^0.4.18;

contract SimpleGacha {

  // ガチャの結果を決めるために必要な変数群
  struct Capsule {
    uint count;  // 何回目のガチャか
    uint height; // 乱数として参照するブロックの高さ
  }

  uint                        public gachaPrice;     // ガチャを引くのに必要な金額
  uint                        public gachaCount = 0; // ガチャが回された回数
  mapping(address => Capsule) public gachaMachine;   // ユーザーと結果を計算する変数の状態
  mapping(address => uint[])  public goods;          // ガチャの結果

  // goodsの個数を計算する
  function goodsCount() public view returns (uint) {
    return goods[msg.sender].length;
  }

  // コンストラクタ
  function SimpleGacha(uint price) public {
    gachaPrice = price;
  }

  // ガチャを引く関数(この時点では結果は決まらない)
  function draw() public payable {
    require (msg.value >= gachaPrice);
    require (gachaMachine[msg.sender].count == 0);

    gachaCount++;
    gachaMachine[msg.sender] = Capsule(gachaCount, block.number);
  }

  // 2つのbytes32を結合する関数
  function concat(bytes32 a, bytes32 b) private pure returns (bytes) {
    bytes memory ab = new bytes(a.length + b.length);
    uint k = 0;
    for (uint i = 0; i < a.length; i++) ab[k++] = a[i];
    for (     i = 0; i < b.length; i++) ab[k++] = b[i];
    return ab;
  }

  // ガチャの結果を取得する関数
  function get() public {
    Capsule storage capsule = gachaMachine[msg.sender];
    require(capsule.height != 0);
    require(block.number >= capsule.height);

    bytes memory concatted = concat(bytes32(capsule.count), block.blockhash(capsule.height));
    uint alpha = uint(sha256(concatted));
    goods[msg.sender].push(alpha);
    delete gachaMachine[msg.sender];
  }
}

運営が乱数を生成する必要が無いため Draw というイベントも無くしてしまいました。

こちらのシンプルなガチャの実装は僕のオリジナルなのでもしかすると考慮できていない攻撃が存在するかもしれません。もし間違っているところがあったり攻撃方法があったりした場合には教えていただけると幸いです。

追記 2018/01/08

複数のスマートコントラクトが同じ仕組みを利用していた時に nh だけでは被ってくる可能性もあるのではという指摘を受けました。これを回避するためにスマートコントラクトのコントラクトアドレス Aハッシュ値の計算時に利用するといいと思います。つまりハッシュの計算は α := H(n||h||A) のようになります。Solidityでは自分自身のコントラクトアドレスは this を使って取得することが可能です。

追記 2018/02/11

Solidityのblock.blockhash関数は直近256ブロックのハッシュ値までしか正しく返してくれずそれ以降は0が返ってきます。その為、ガチャを引くユーザーはコントラクトの実行が確定したら急いで取り出す必要がありますが、ネットワークが混雑している場合などコントラクトの実行がいつ確定するかわからないような状況になると正しくガチャを引けない可能性があります。さらにガチャを引くユーザーが敢えてガチャを受け取らずにハッシュが0のパターンで引き出す可能性もあります。もしハッシュを0に固定できれば他の値は簡単に操作できるため不正にレアアイテムを取得されてしまう可能性があります。