Мало кто нынче не слышал о криптовалютах и, в частности, 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 вручную на странице контракта.
ссылка на оригинал статьи https://habrahabr.ru/post/325988/
Добавить комментарий