Pull to refresh

Проверяем честность игры в рулетку на смарт-контракте Ethereum

Open source *Reverse engineering *Game testing *
Sandbox


Мало кто нынче не слышал о криптовалютах и, в частности, Bitcoin. В 2014-м году, на волне интереса к биткоину, появилась новая криптовалюта — Ethereum. Сегодня, в 2017-м, она является второй по капитализации после биткоина. Одним из важнейших её отличий от биткоина является использование тьюринг-полной виртуальной машины — EVM. Подробнее про эфир можно прочитать в его Yellow Paper.

Смарт-контракты Ethereum обычно пишут на языке Solidity. На Хабре уже были статьи про написание и тестирование смарт-контрактов, например 1, 2, 3. А про связь смарт-контракта с сайтом можно почитать, например, статью о создании простейшей голосовалки на смарт-контракте. В этой статье используется встроенный в кошелёк Mist броузер, но то же самое можно делать используя плагин к Chrome, например MetaMask. Именно так, через MetaMask, и работает игра, которую мы будем исследовать.

Игра является реализацией реализацией европейской рулетки: на поле 37 клеток, пронумерованных от 0 до 36. Можно делать ставку на конкретный номер либо на набор номеров: чётные/нечётные, красное/черное, 1-12, 1-18 и т.д. В каждом раунде можно сделать несколько ставок путём добавления жетона (стоимостью 0.01 ETH ≈ $0.5) на соответствующее поле игрового стола. Каждому полю соответствует коэффициент выигрыша. Например, ставке «на красное» соответствует коэффициент 2 — то есть заплатив 0.01 ETH вы, в случае выигрыша, получите 0.02 ETH. А если поставите на зеро, то коэффициент будет 36: заплатив тот же 0.01 ETH за ставку вы получите 0.36 в случае выигрыша.

Разработчики, правда, используют другое обозначение: 35:1
В коде контракта коэффициент для этой ставки тоже указан как 35, а к сумме выигрыша, перед выплатой, прибавляется сумма ставки. Возможно, в игровом мире принято именно такое обозначение, но мне логичнее сразу использовать 36.

Когда все ставки сделаны, игрок нажимает кнопку «Играть» и, через MetaMask, отправляет пари* в Ethereum-блокчейн, на адрес смарт-контракта игры. Контракт определяет выпавшее число, рассчитывает результаты ставок и, в случае необходимости, отсылает выигрыш игроку.
* — Я буду использовать термин пари для обозначения набора ставок (т.е. пар тип ставки — количество жетонов на ставку), которые игрок делает в рамках одного раунда. Если вы знаете более корректный термин, напишите мне пожалуйста, я исправлю.

Для того, чтобы понять честно ли работает игра (то есть, не манипулирует ли казино определением выпавшего числа в свою пользу) проанализируем работу смарт-контракта.

Его адрес указан на сайте игры. Кроме того, перед подтверждением оплаты можно проверить на какой адрес будет отправлено пари. Я проанализирую контракт по адресу 0xDfC328c19C8De45ac0117f836646378c10e0CdA3. Etherscan показывает его код, а для удобного просмотра можно использовать Solidity Browser.

Работа контракта начинается с вызова функции placeBet():

Простыня кода
function placeBet(uint256 bets, bytes32 values1,bytes32 values2) public payable
{
   if (ContractState == false)
   {
     ErrorLog(msg.sender, "ContractDisabled");
     if (msg.sender.send(msg.value) == false) throw;
     return;
   }

   var gamblesLength = gambles.length;

   if (gamblesLength > 0)
   {
      uint8 gamblesCountInCurrentBlock = 0;
      for(var i = gamblesLength - 1;i > 0; i--)
      {
        if (gambles[i].blockNumber == block.number) 
        {
           if (gambles[i].player == msg.sender)
           {
               ErrorLog(msg.sender, "Play twice the same block");
               if (msg.sender.send(msg.value) == false) throw;
               return;
           }

           gamblesCountInCurrentBlock++;
           if (gamblesCountInCurrentBlock >= maxGamblesPerBlock)
           {
              ErrorLog(msg.sender, "maxGamblesPerBlock");
              if (msg.sender.send(msg.value) == false) throw;
              return;
           }
        }
        else
        {
           break;
        }
      }
   }
   
   var _currentMaxBet = currentMaxBet;

   if (msg.value < _currentMaxBet/256 || bets == 0)
   {
      ErrorLog(msg.sender, "Wrong bet value");
      if (msg.sender.send(msg.value) == false) throw;
      return;
   }

   if (msg.value > _currentMaxBet)
   {
      ErrorLog(msg.sender, "Limit for table");
      if (msg.sender.send(msg.value) == false) throw;
      return;
   }

   GameInfo memory g = GameInfo(msg.sender, block.number, 37, bets, values1,values2);

   if (totalBetValue(g) != msg.value)
   {
      ErrorLog(msg.sender, "Wrong bet value");
      if (msg.sender.send(msg.value) == false) throw;
      return;
   }       

   address affiliate = 0;
   uint16 coef_affiliate = 0;
   uint16 coef_player;
   if (address(smartAffiliateContract) > 0)
   {        
     (affiliate, coef_affiliate, coef_player) = smartAffiliateContract.getAffiliateInfo(msg.sender);   
   }
   else
   {
     coef_player = CoefPlayerEmission;
   }

   uint256 playerTokens;
   uint8 errorCodeEmission;
   
   (playerTokens, errorCodeEmission) = smartToken.emission(msg.sender, affiliate, msg.value, coef_player, coef_affiliate);
   if (errorCodeEmission != 0)
   {
      if (errorCodeEmission == 1) 
        ErrorLog(msg.sender, "token operations stopped");
      else if (errorCodeEmission == 2) 
        ErrorLog(msg.sender, "contract is not in a games list");
      else if (errorCodeEmission == 3) 
        ErrorLog(msg.sender, "incorect player address");
      else if (errorCodeEmission == 4) 
        ErrorLog(msg.sender, "incorect value bet");
      else if (errorCodeEmission == 5) 
        ErrorLog(msg.sender, "incorect Coefficient emissions");
      
      if (msg.sender.send(msg.value) == false) throw;
      return;
   }

   gambles.push(g);

   PlayerBet(gamblesLength, playerTokens); 
}

Для новичков в Solidity поясню, что модификаторы public и payable означают, что функция является частью API контракта и что при её вызове можно отправить эфир. При этом информация об отправителе и количестве отправленного эфира будет доступна через переменную msg.

Параметрами вызова является битовая маска типов ставок и два 32-байтных массива с количеством жетонов на каждый из типов. Догадаться об этом можно посмотрев на определение типа GameInfo и функций getBetValueByGamble(), getBetValue().

Ещё одна простыня кода, поменьше
struct GameInfo
{
    address player;
    uint256 blockNumber;
    uint8 wheelResult;
    uint256 bets;
    bytes32 values;
    bytes32 values2;
}

// n - number player bet
// nBit - betIndex
function getBetValueByGamble(GameInfo memory gamble, uint8 n, uint8 nBit) private constant returns (uint256) 
{
  if (n <= 32) return getBetValue(gamble.values , n, nBit);
  if (n <= 64) return getBetValue(gamble.values2, n - 32, nBit);
  // there are 64 maximum unique bets (positions) in one game
  throw;
}
// n form 1 <= to <= 32
function getBetValue(bytes32 values, uint8 n, uint8 nBit) private constant returns (uint256)
{
    // bet in credits (1..256) 
    uint256 bet = uint256(values[32 - n]) + 1;

    if (bet < uint256(minCreditsOnBet[nBit]+1)) throw;   //default: bet < 0+1
    if (bet > uint256(256-maxCreditsOnBet[nBit])) throw; //default: bet > 256-0      

    return currentMaxBet * bet / 256;        
}

Отмечу, что getBetValue() возвращает сумму ставки уже не в жетонах, а в wei.

Первым делом placeBet() проверяет, что контракт не выключен и затем начинаются проверки пари.
Массив gambles является хранилищем всех сыгранных в данном контракте пари. placeBet() находит все пари в своём блоке и проверяет не присылал ли данный игрок другое пари в этом блоке и не превышено ли разрешенное количество пари на блок. Затем проверяются ограничения на минимальную и максимальную сумму ставки.

В случае любой ошибки выполнение контракта прерывается командой throw, которая откатывает транзакцию, возвращая эфир игроку.

Далее переданные в функцию параметры сохраняются в структуре GameInfo.Здесь нам важно, что поле wheelResult инициализируется числом 37.

После ещё одной проверки, что сумма ставок совпадает с присланным количеством эфира происходит распределение токенов RLT, обрабатывается реферальная программа, информация о пари сохраняется в gambles и создаётся событие PlayerBet с номером и суммой пари, которое затем видно в веб-части игры.

Про токены
При каждой ставке игроку выдаётся определённое количество RLT, Ethereum-токенов, которыми определяется право владельца токенов на получение дивидендов с прибыли, полученной авторами игры. Подробнее об этом — читайте White Paper.

Дальнейшая жизнь пари начинается с вызова функции ProcessGames(), который, после появления новой ставки, выполняется, в настоящее время, с адреса 0xa92d36dc1ca4f505f1886503a0626c4aa8106497. Такие вызовы хороши видны при просмотре списка транзакций контракта игры: у них Value = 0.
Код ProcessGames
function ProcessGames(uint256[] gameIndexes, bool simulate) 
{
  if (!simulate)
  {
     if (lastBlockGamesProcessed == block.number)  return;
     lastBlockGamesProcessed = block.number;
  }

  uint8 delay = BlockDelay;
  uint256 length = gameIndexes.length;
  bool success = false;
  for(uint256 i = 0;i < length;i++)
  {      
     if (ProcessGame(gameIndexes[i], delay) == GameStatus.Success) success = true;         
  }      
  if (simulate && !success) throw;
}
В параметрах вызова передаётся массив с номерами пари, которые требуют расчёта, и для каждого вызывается ProcessGame.

function ProcessGame(uint256 index, uint256 delay) private returns (GameStatus)
{            
  GameInfo memory g = gambles[index];
  if (block.number - g.blockNumber >= 256) return GameStatus.Stop;

  if (g.wheelResult == 37 && block.number > g.blockNumber + delay)
  {            
     gambles[index].wheelResult = getRandomNumber(g.player, g.blockNumber);
             
     uint256 playerWinnings = getGameResult(gambles[index]);
     if (playerWinnings > 0) 
     {
        if (g.player.send(playerWinnings) == false) throw;
     }

     EndGame(g.player, gambles[index].wheelResult, index);
     return GameStatus.Success;
  }

  return GameStatus.Skipped;
}

Параметрами вызова являются номер ставки и количество блоков которое должно пройти между ставкой и её обработкой. При вызове из ProcessGames() или ProcessGameExt() этот параметр в настоящее время равен 1, это значение можно узнать из результата вызова getSettings().

В случае, если номер блока, в котором происходит обработка, больше чем на 255 блоков отстоит от блока пари, оно не может быть обработано: хэш блока доступен только для последних 256 блоков, а он нужен для определения выпавшего числа.

Далее выполняется проверка не был ли уже рассчитан результат игры (помните, wheelResult инициализровался числом 37, которое выпасть не может?) и прошло ли уже необходимое количество блоков.

Если условия выполнены, производится вызов getRandomNumber() для определения выпавшего числа, вызовом getGameResult() рассчитывается выигрыш. Если он не нулевой, эфир посылается игроку: g.player.send(playerWinnings). Затем создаётся событие EndGame, которое может быть прочитано из веб-части игры.

Посмотрим самое интересное, то как определяется выпавшее число: функцию getRandomNumber().

function getRandomNumber(address player, uint256 playerblock) private returns(uint8 wheelResult)
{
    // block.blockhash - hash of the given block - only works for 256 most recent blocks excluding current
    bytes32 blockHash = block.blockhash(playerblock+BlockDelay); 
    
    if (blockHash==0) 
    {
      ErrorLog(msg.sender, "Cannot generate random number");
      wheelResult = 200;
    }
    else
    {
      bytes32 shaPlayer = sha3(player, blockHash);

      wheelResult = uint8(uint256(shaPlayer)%37);
    }    
}

Её аргументы — адрес игрока и номер блока, в котором была сделана ставка. Первым делом функция получает хэш блока, отстоящего от блока ставки на BlockDelay блоков в будущее.

Это важный момент, поскольку если игрок сможет каким-то образом узнать хэш этого блока заранее, он может сформировать ставку, которая гарантированно выиграет. Если вспомнить, что в Ethereum существуют Uncle-блоки, тут может быть проблема и требуется дальнейший анализ.

Далее рассчитывается SHA-3 от склейки адреса игрока и полученного хэша блока. Выпавшее число вычисляется путём взятия остатка от деления результата SHA-3 на 37.

С моей точки зрения, алгоритм вполне честный и казино никакого преимущества перед игроком не имеет.
Почему казино приносить владельцам прибыль?
На поле 37 клеток. Предположим, я захочу сделать 100 000 ставок по одному жетону на одно конкретное поле.
Вероятно, примерно 2703 раз я выиграю, а все остальные разы проиграю. При этом за выигрыши от казино я получу 2703*36 = 97 308 жетонов. А 2692 жетона, потраченные мною на ставки, останутся у казино.
Аналогичные расчёты можно провести для всех остальных типов ставок.


Интересно посмотреть, также, как рассчитывается выигрыш. Как мы видели, это делает функция getGameResult().

function getGameResult(GameInfo memory game) private constant returns (uint256 totalWin) 
{
    totalWin = 0;
    uint8 nPlayerBetNo = 0;
    // we sent count bets at last byte 
    uint8 betsCount = uint8(bytes32(game.bets)[0]); 
    for(uint8 i=0; i<maxTypeBets; i++)
    {                      
        if (isBitSet(game.bets, i))
        {              
          var winMul = winMatrix.getCoeff(getIndex(i, game.wheelResult)); // get win coef
          if (winMul > 0) winMul++; // + return player bet
          totalWin += winMul * getBetValueByGamble(game, nPlayerBetNo+1,i);
          nPlayerBetNo++; 

          if (betsCount == 1) break;
          betsCount--;
        }
    }        
}

Параметром сюда передаётся структура GameInfo с данными о рассчитываемом пари. А её поле wheelResult уже заполнено выпавшим числом.

Видим цикл по всем типам ставок, в котором проверяется битовая маска game.bets и если бит проверяемого типа установлен, то запрашивается winMatrix.getCoeff(). winMatrix — это контракт по адресу 0x073D6621E9150bFf9d1D450caAd3c790b6F071F2, загруженный в конструкторе SmartRoulettee().

В качестве параметра этой функции передаётся комбинация типа ставки и выпавшего числа:

// unique combination of bet and wheelResult, used for access to WinMatrix
function getIndex(uint16 bet, uint16 wheelResult) private constant returns (uint16)
{
  return (bet+1)*256 + (wheelResult+1);
}

Разбор кода контракта WinMatrix я оставлю вам в качестве домашнего задания, но ничего неожиданного там нет: генерируется матрица коэфициентов и при вызове getCoeff() возвращается нужный. При желании его легко проверить вызовом этой функции вручную на странице контракта.
Tags: смарт-контрактыethereum
Hubs: Open source Reverse engineering Game testing
Total votes 25: ↑25 and ↓0 +25
Comments 46
Comments Comments 46

Popular right now