11

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

 3 years ago
source link: https://learnblockchain.cn/article/2154
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
如何设计以太坊上的高额赌注随机数游戏 | 登链社区 | 深入浅出区块链技术

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

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

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

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

先来讨论一下整体设计。

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

安全地生成一个随机数

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

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

高额赌注方案: 所以对于高赌注的情况,我们需要一个更好的随机数发生器。 幸运的是,在有两个参与者(在本文中,我们设定两个玩家为:银行和玩家)的设置中,我们可以使用承诺模式。 每个玩家承诺秘密随机数,首先发送该数字的 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 赞助支持。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK