Мало кто нынче не слышал о криптовалютах и, в частности, 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 в случае выигрыша.
Когда все ставки сделаны, игрок нажимает кнопку «Играть» и, через 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 с номером и суммой пари, которое затем видно в веб-части игры.
Дальнейшая жизнь пари начинается, на сколько я понимаю, с вызова функции 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