如何设计以太坊上的高额赌注随机数游戏

通过实例学习,建立一个安全的高赌注随机数游戏

在上一篇,我们介绍了区块链与随机数,介绍使用承诺模式来安全在智能合约中生成一个随机数。

举一反三的学习总是最好的。 所以,让我们在区块链上建立一个真实的随机数游戏。 我们将通过增加一个安全的随机数生成器,使其足够安全,允许游戏支持真正的高赌注。

先来讨论一下整体设计。

设计合约

作为开发人员,在进行任何编程之前,我们要正确规划和设计我们的系统。 在随机数游戏中,我们需要弄清楚如何创建一个随机数,如何管理资金以及如何处理超时。

安全地生成一个随机数

合约的核心将是一个承诺模式。在这里可以查看之前的教程。 但总的来说,区块链中的随机数没有很好的直接来源,因为所有的代码都要确定性地运行。

低赌注的方案: 使用未来的区块哈希是一个可能的解决方案,但矿工对这个值有一定的影响。 他们可以选择不发布新区块,放弃区块奖励。 但如果他们同时在玩一个非常高赌注的随机数游戏,阻止一个区块可能是他们更好的收益策略。

高额赌注方案: 所以对于高赌注的情况,我们需要一个更好的随机数发生器。 幸运的是,在有两个参与者(在本文中,我们设定两个玩家为:银行和玩家)的设置中,我们可以使用承诺模式。 每个玩家承诺秘密随机数,首先发送该数字的keccak256承诺哈希值。 一旦两个哈希值都在合约中,玩家就可以安全地揭示实际的随机数。 该合约验证了keccak256(randomNumber)==承诺哈希,确保双方不能再更改随机数。 最后的随机数将是这个随机数的运算(如:randomNumberBank XOR randomNumberPlayer)。 更多的细节在可参看区块链与随机数

随机数

完善高额赌注下的设计

针对这种高额赌注的情况,可以从两个方面进行改进:

  1. 让银行先承诺一个秘密数值发送哈希,其实就足够了。 当银行提交后,玩家可以直接揭示自己的数值。 这样就减少了一个游戏回合,避免了玩家也发送承诺哈希。
  2. 假设一个玩家不会只玩一个回合,我们可以建立一个承诺哈希链。 该链的计算方式为:keccak256(keccak256(keccak256( ... (randomNumber) ...))。 基本上是重复的计算出哈希的哈希值,以此类推,数百万次。最后一个哈希值将发送作为第一个承诺,而所有其他中间哈希值则存储起来供以后使用。 然后,银行揭示的秘密值又将自动作为下一轮的承诺。

利用这两项改进,单场比赛的回合数就可以减少到:

  1. 玩家在下注的同时发送一个秘密数值。 为了简单起见,我们只允许投注红色或黑色,但应该很容易扩展该功能。
  2. 银行发送秘密数值时(揭示阶段),将自动触发支付。

基金管理

我们不想为每一轮游戏发资金。 所以就像在现实世界中一样,我们的赌场会管理自己的资金。 玩家和银行在合约中存入资金,并获得游戏内资金。 他们可以随时提取任何解锁资金或存入更多资金。

处理超时

在承诺模式中,有一种方法可以操纵结果:不发送秘密数值,从而阻止一个回合的结束。 为了处理这种情况,我们需要为玩家提供一个额外的功能,检查银行是否在规定的时间内发送了秘密数值。如果没有发送,则玩家自动获胜。

管理合约中的资金

可以在存储中声明一个简单的映射。

mapping (address => uint256) public registeredFunds;

在这里,我们可以在发送ETH时记录地址存入的金额,或者在取出ETH时减去提取的金额。 你同样可以用ERC-20代币代替ETH。

我们使用.call方法而不是.transfer,因为transfer是不推荐发送ETH的方式了。

function depositFunds() external payable {
  require(msg.value > 0, "Must send ETH");
  registeredFunds[msg.sender] += msg.value;
}

function withdrawFunds() external {
  require(registeredFunds[msg.sender] > 0);

  uint256 funds = registeredFunds[msg.sender];
  registeredFunds[msg.sender] = 0;

  (bool success, ) = msg.sender.call{value: funds}("");
  require(success, "ETH transfer failed");
}

下注

接下来让我们来创建实际的游戏。 一轮比赛将由GameRound结构来定义。 下面几个值将用来生成随机数:

  • bankHash
  • bankSecretValue
  • userValue

userValue作为选择是赌红还是赌黑(更好的编码方式可能是在这里使用一个枚举,有两个值RED和BLACK)。 将赢取的资金作为lockedFunds。 对于红/黑的投注,lockedFunds将是投注额的两倍。 并且我们还需要存储下注的时间,以实现超时功能。

struct GameRound {
    bytes32 bankHash;
    uint256 bankSecretValue;
    uint256 userValue;
    bool hasUserBetOnRed;
    uint256 timeWhenSecretUserValueSubmitted;
    uint256 lockedFunds;
}

现在我们可以创建一个placeBet函数。 确保游戏处于正确的状态,并确保银行和玩家有足够的资金。 我们会存储赌注,锁定资金,存储超时时间。

为什么我们为每个玩家存储一个银行哈希值?你可能会好奇为什么我们不在所有游戏中只使用一个银行哈希值。 这似乎很诱人,因为它减少了银行的复杂性。 不幸的是,它将允许完全操纵随机数。 想象一下,多个玩家同时下注。 现在,银行可以决定为哪位玩家揭示秘密数值。 为了防止这种情况的发生,我们需要根据投注的时间,对每一次的揭晓执行严格的顺序。 这最终会比每个玩家有一个哈希更复杂。

随机42

function placeBet(bool hasUserBetOnRed, uint256 userValue,uint256 _betAmount) external {
    require(gameRounds[msg.sender].bankHash != 0x0, "Bank hash not yet set");
    require(userValue == 0, "Already placed bet");
    require(registeredFunds[bankAddress] >= _betAmount, "Not enough bank funds");
    require(registeredFunds[msg.sender] >= _betAmount, "Not enough user funds");

    gameRounds[msg.sender].userValue = userValue;
    gameRounds[msg.sender].hasUserBetOnRed = hasUserBetOnRed;
    gameRounds[msg.sender].lockedFunds = _betAmount * 2;
    gameRounds[userAddress].timeWhenSecretUserValueSubmitted = block.timestamp;

    registeredFunds[msg.sender] -= _betAmount;
    registeredFunds[bankAddress] -= _betAmount;
}

你可能已经注意到,在第一轮之前,bankhash 会是空的。 所以我们需要两个额外的函数,这些函数只在开始时被玩家调用一次。 通过initializeGame玩家可以请求银行调用setInitialBankHash

function initializeGame() external {
    require(!hasRequestedGame[msg.sender],"Already requested");

    hasRequestedGame[msg.sender] = true;
    emit NewGameRequest(msg.sender);
}

银行将运行一个服务器,监听NewGameRequest事件。 收到事件后,将调用 setInitialBankHash。

function setInitialBankHash(
    bytes32 bankHash,
    address user
) external onlyOwner {
    require(
        gameRounds[user].bankHash == 0x0,
        "Bank hash already set"
    );
    gameRounds[user].bankHash = bankHash;
}

银行揭示秘密数值

现在进行实际游戏,银行需要揭示数值。我们要求游戏回合确实是在(等待)银行揭示数值状态下。 同时我们确保hashReveal等于gameRounds[userAddress].bankHash,因此强制要求银行不能操纵随机数。

function sendBankSecretValue(uint256 bankSecretValue, address user) external {
    require(gameRounds[userAddress].userValue != 0, "User has no value set");
    require(gameRounds[userAddress].bankSecretValue == 0, "Already revealed");
    bytes32 hashedReveal = keccak256(abi.encodePacked(bankSecretValue));
    require(hashedReveal == gameRounds[userAddress].bankHash, "Bank reveal not matching commitment");

    gameRounds[userAddress].bankSecretValue = bankSecretValue;

    _evaluateBet(user);
    _resetContractFor(user);

    gameRounds[userAddress].bankHash = bytes32(bankSecretValue);
}

确定结果

然后我们确定结果,看看谁赢了。 最后我们重新设置下一轮的数据,其中包括自动将银行哈希值设置为当前的秘密数值(根据我们在开始时描述的承诺哈希链设计)。

function _resetContractFor(address user) private {
    gameRounds[user] = GameRound(0x0, 0, 0, false, 0, 0);
}

function _evaluateBet(address user) private {
    uint256 random = gameRounds[user].bankSecretValue
        ^ gameRounds[user].userValue;
    uint256 number = random % ROULETTE_NUMBER_COUNT;
    uint256 winningAmount = gameRounds[user].lockedFunds;

    bool isNeitherRedNorBlack = number == 0;
    bool isRed = isNumberRed[number];
    bool hasUserBetOnRed = gameRounds[user].hasUserBetOnRed;

    address winner;

    if (isNeitherRedNorBlack) winner = bankAddress;
    else if (isRed == hasUserBetOnRed) winner = userAddress;
    else winner = bankAddress;

    registeredFunds[winner] += winningAmount;
}

我们现在有两个由玩家和银行随机选择的号码。 使用按位 OR 可以计算出一个最终的随机数。

使用 随机数 % ROULETTE_NUMBER_COUNT,例如,计算随机数模37,将得到一个0到36之间的随机数,获得任何数字都有相同的概率。

现在对于选出优胜者,我们有三个情况:

  1. 数字是0: 银行赢
  2. 颜色被玩家猜对了
  3. 颜色没有被玩家猜对

为了确定颜色是否为红色,我们可以使用存储bool[37] isNumberRed数组来定义。

处理超时

确定预先定义的超时时间TIMEOUT_FOR_BANK_REVEAL(例如2天),我们可以检查是否有超时。 如果游戏确实是在等待银行发送揭示,并且等待的时间超过了超时时间,玩家可以调用checkBankSecretValueTimeout,将自动赢得游戏回合。

function checkBankSecretValueTimeout() external {
   require(gameRounds[msg.sender].bankHash != 0, "Bank hash not set");
   require(gameRounds[msg.sender].bankSecretValue == 0, "Bank secret is set");
   require(gameRounds[msg.sender].userValue != 0, "User value not set");

   uint256 timeout = (gameRounds[msg.sender].timeWhenSecretUserValueSubmitted + TIMEOUT_FOR_BANK_REVEAL);
   require(block.timestamp > timeout, "Timeout not yet reached");

   registeredFunds[msg.sender] += gameRounds[msg.sender].lockedFunds;
   _resetContractFor(msg.sender);
   hasRequestedGame[msg.sender] = false;
}

完全可行的例子 + 缺少的组件

完整可行的代码如下:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract BankOwned {
    address public bankAddress;

    constructor() {
        bankAddress = msg.sender;
    }

    modifier onlyOwner {
        require(msg.sender == bankAddress);
        _;
    }
}

contract Roulette is BankOwned {
    uint256 public immutable TIMEOUT_FOR_BANK_REVEAL = 1 days;
    uint256 public immutable ROULETTE_NUMBER_COUNT = 37;

    // prettier-ignore
    bool[37] isNumberRed = [false, true, false, true, false, true, false, true, false, true, false, false, true, false, true, false, true, false, true, true, false, true, false, true, false, true, false, true, false, false, true, false, true, false, true, false, true];

    struct GameRound {
        bytes32 bankHash;
        uint256 bankSecretValue;
        uint256 userValue;
        bool hasUserBetOnRed;
        uint256 timeWhenSecretUserValueSubmitted;
        uint256 lockedFunds;
    }

    mapping(address => bool) public hasRequestedGame;
    mapping(address => GameRound) public gameRounds;
    mapping(address => uint256) public registeredFunds;

    event NewGameRequest(address indexed user);

    function increaseFunds() external payable {
        require(msg.value > 0, "Must send ETH");
        registeredFunds[msg.sender] += msg.value;
    }

    function withdrawMoney() external {
        require(registeredFunds[msg.sender] > 0);

        uint256 funds = registeredFunds[msg.sender];
        registeredFunds[msg.sender] = 0;

        (bool wasSuccessful, ) = msg.sender.call{value: funds}("");
        require(wasSuccessful, "ETH transfer failed");
    }

    function initializeGame() external {
        require(!hasRequestedGame[msg.sender], "Already requested game");

        hasRequestedGame[msg.sender] = true;
        emit NewGameRequest(msg.sender);
    }

    function setInitialBankHash(bytes32 bankHash, address userAddress) external onlyOwner {
        require(gameRounds[userAddress].bankHash == 0x0, "Bank hash already set");
        gameRounds[userAddress].bankHash = bankHash;
    }

    function placeBet(
        bool hasUserBetOnRed,
        uint256 userValue,
        uint256 _betAmount
    ) external {
        require(gameRounds[msg.sender].bankHash != 0x0, "Bank hash not yet set");
        require(userValue == 0, "Already placed bet");
        require(registeredFunds[bankAddress] >= _betAmount, "Not enough bank funds");
        require(registeredFunds[msg.sender] >= _betAmount, "Not enough user funds");

        gameRounds[msg.sender].userValue = userValue;
        gameRounds[msg.sender].hasUserBetOnRed = hasUserBetOnRed;
        gameRounds[msg.sender].lockedFunds = _betAmount * 2;
        gameRounds[userAddress].timeWhenSecretUserValueSubmitted = block.timestamp;

        registeredFunds[msg.sender] -= _betAmount;
        registeredFunds[bankAddress] -= _betAmount;
    }

    function sendBankSecretValue(uint256 bankSecretValue, address userAddress) external {
        require(gameRounds[userAddress].userValue != 0, "User has no value set");
        require(gameRounds[userAddress].bankSecretValue == 0, "Already revealed");
        require(keccak256(abi.encodePacked(bankSecretValue)) == gameRounds[userAddress].bankHash, "Bank reveal not matching commitment");

        gameRounds[userAddress].bankSecretValue = bankSecretValue;

        _evaluateBet(userAddress);
        _resetContractFor(userAddress);

        gameRounds[userAddress].bankHash = bytes32(bankSecretValue);
    }

    function checkBankSecretValueTimeout() external {
        require(gameRounds[msg.sender].bankHash != 0, "Bank hash not set");
        require(gameRounds[msg.sender].bankSecretValue == 0, "Bank secret is set");
        require(gameRounds[msg.sender].userValue != 0, "User value not set");
        require(block.timestamp > (gameRounds[msg.sender].timeWhenSecretUserValueSubmitted + TIMEOUT_FOR_BANK_REVEAL), "Timeout not yet reached");

        registeredFunds[msg.sender] += gameRounds[msg.sender].lockedFunds;
        _resetContractFor(msg.sender);
    }

    function _resetContractFor(address userAddress) private {
        gameRounds[userAddress] = GameRound(0x0, 0, 0, false, 0, 0);
    }

    function _evaluateBet(address userAddress) private {
        uint256 random = gameRounds[userAddress].bankSecretValue ^ gameRounds[userAddress].userValue;
        uint256 number = random % ROULETTE_NUMBER_COUNT;
        uint256 winningAmount = gameRounds[userAddress].lockedFunds;

        bool isNeitherRedNorBlack = number == 0;
        bool isRed = isNumberRed[number];
        bool hasUserBetOnRed = gameRounds[userAddress].hasUserBetOnRed;

        address winner;

        if (isNeitherRedNorBlack) winner = bankAddress;
        else if (isRed == hasUserBetOnRed) winner = userAddress;
        else winner = bankAddress;

        registeredFunds[winner] += winningAmount;
    }
}

此代码也可以在这里找到。 不过你要知道,这只是合约代码。 作为银行提供者,你需要一个后台服务器运行,处理监听新投注和发送承诺哈希的逻辑。 通过允许银行同时为多个玩家提交多个哈希,可以进一步改进 gas 消耗。

另外,一个漂亮的前端界面也肯定受玩家们欢迎。


本翻译由 Cell Network 赞助支持。

点赞 3
收藏 1
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
翻译小组
翻译小组
0x9e64...7c84
大家看到好的文章可以在 GitHub 提 Issue: https://github.com/lbc-team/Pioneer/issues 欢迎关注我的 Twitter: https://twitter.com/UpchainDAO