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

в 14:33, , рубрики: Ethereum, open source, реверс-инжиниринг, Тестирование игр, метки:

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

Мало кто нынче не слышал о криптовалютах и, в частности, 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. Далее идёт проверка, что контракт не выключен и начинаются проверки самого пари. Массив gambles является хранилищем всех сыгранных в данном контракте пари. placeBet() находит все пари в своём блоке и проверяет не присылал ли данный игрок другое пари в этом блоке и не превышено ли разрешенное количество пари на блок. Затем проверяются ограничения на минимальную и максимальную сумму ставки.

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

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

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

Про токены

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

Дальнейшая жизнь пари начинается, на сколько я понимаю, с вызова функции ProcessGames либо ProcessGameExt. Я не вполне разобрался как это происходит (если кто знает — расскажите пожалуйста, добавлю в статью), но в любом случае, это приводит к вызову 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.

С моей точки зрения, алгоритм вполне честный и казино никакого преимущества перед игроком не имеет.

Интересно посмотреть, также, как рассчитывается коэффициент выигрыша. Как мы видели, это делает функция 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() возвращается нужный. При желании его легко проверить вызовом readCoeff вручную на странице контракта.

Автор: pash7ka

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js