基于 sCrypt 合约开发一个完整的 dApp:井字棋游戏

在我们之前的博客中,介绍了如何使用 sCrypt 来编写 BSV 智能合约。但是作为刚入门的开发者,你可能对如何使用 sCrypt 来构建 dApp 更加感兴趣。接下来我们将教大家如何使用 sCrypt 一步一步地构建一个井字棋 dApp.

该应用程序非常简单,它所做的就是使用两个玩家(分别是 Alice 和 Bob)的公钥,初始化合约,只有赢得那玩家可以取走合约里面的 bsv。如果最后没有人赢,则两个玩家各自可以取走一半。我们将向您展示如何在 BSV 区块链上构建去中心化应用程序(又名 dApp),包括:

  1. 编写合约
  2. 测试合约
  3. 集成一个简单的 Web 应用程序与合约进行交互

到最后,你将拥有一个运行在 BSV 上的井字棋游戏dApp

在这里插入图片描述

搭建开发环境

  1. 安装 sCrypt IDE,见 sCrypt 开发工具篇 - Visual Studio Code 插件
  2. 安装 nodejs, version >= 12
  3. 安装 Typescript

使用 Git 克隆 React App 项目 tic-tac-toe, 并切换到的 webapp 分支。 该分支包含一个只有前端代码的井字棋游戏。然后在根目录下创建一个 contractstest目录,分别用来存放合约代码和合约的测试代码。你将看到以下目录结构。

在这里插入图片描述

使用 sCrypt 编写 tic-tac-toe 合约

TicTacToe 合约主要实现原理是通过有状态合约将游戏的状态存储在合约中。井字棋游戏状态由以下组成:

  1. turn : 布尔类型。表示轮到谁下棋, true 表示轮到 Alice, false 表示轮到 Bob
  2. board : 整数数组类型。记录棋盘当前的状态,每个元素代表棋盘的一个位置,0 表示没有棋子,1 表示 ALICE的棋子,2 表示BOB的棋子,长度为 9

下面是带有注释的合约代码。


contract TicTacToe {
    PubKey alice;
    PubKey bob;

    // if it is alice's turn to play
    @state
    bool isAliceTurn;

    // state of the board. For example, a board with Alice in the first row 
    // and first column is expressed as [1,0,0,0,0,0,0,0,0]
    @state
    int[N] board;

    static const int N = 9;
    static const int EMPTY = 0;
    static const int ALICE = 1;
    static const int BOB = 2;

    public function move(int n, Sig sig, int amount, SigHashPreimage txPreimage) {

        require(Tx.checkPreimage(txPreimage));
        require(n >= 0 && n < N);

        // not filled
        require(this.board[n] == EMPTY);

        int play = this.isAliceTurn ? ALICE : BOB;
        PubKey player = this.isAliceTurn ? this.alice : this.bob;

        // ensure it's player's turn
        require(checkSig(sig, player));
        // make the move
        this.board[n] = play;
        this.isAliceTurn = !this.isAliceTurn;

        bytes outputs = b'';
        if (this.won(play)) {
            bytes outputScript = Utils.buildPublicKeyHashScript(hash160(player));
            bytes output = Utils.buildOutput(outputScript, amount);
            outputs = output;
        }
        else if (this.full()) {
            bytes aliceScript = Utils.buildPublicKeyHashScript(hash160(this.alice));
            bytes aliceOutput = Utils.buildOutput(aliceScript, amount);

            bytes bobScript = Utils.buildPublicKeyHashScript(hash160(this.bob));
            bytes bobOutput = Utils.buildOutput(bobScript, amount);

            outputs = aliceOutput + bobOutput;
        }
        else {
            bytes scriptCode_ = this.getStateScript();
            bytes output = Utils.buildOutput(scriptCode_, amount);
            outputs = output;
        }

        require(hash256(outputs) == SigHash.hashOutputs(txPreimage));
    }

    function won(int play) : bool {
        // three in a row, a column, or a diagonal
        int[8][3] lines = [[0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6]];

        bool anyLine = false;
        loop (8) : i {
            bool line = true;
            loop (3) : j {
                line = line && this.board[lines[i][j]] == play;
            }

            anyLine = anyLine || line;
        }

        return anyLine;
    }

    function full() : bool {
        bool full = true;

        loop (N) : i {
            full = full && this.board[i] != TicTacToe.EMPTY;
        }

        return full;
    }
}
TicTacToe 合约源代码

游戏规则

首先,通过钱包将一定数量的 BSV 锁定在一个包含上述合约的 UTXO 中。接下来,Alice 和 Bob 通过调用公共函数 move() 交替玩游戏:

  1. 如果玩家获胜,他/她将拿走合约中锁定的所有资金
  2. 如果棋盘已满且无人获胜,则为平局,Alice 和 Bob 各拿一半的资金
  3. 否则,游戏仍在进行中,下一个玩家移动棋子

scryptlib

dApp 需要在前端页面与合约进行交互。 要做到这一点,我们将使用 sCrypt 官方发布的 JavaScript 库 —— scryptlib.

scryptlib 是用于集成以 sCrypt 语言编写的 Bitcoin SV 智能合约的 Javascript/TypeScript SDK。

通过 scryptlib ,你就能方便地编译,测试,部署,调用合约了。

scryptlib 安装

scryptlib 可以通过 npm 安装。

// use NPM
npm install scryptlib

// use Yarn
yarn add scryptlib

使用 scryptlib 实例化和调用合约公共方法的代码看起来像:

const Demo = buildContractClass(compileContract('demo.scrypt'));
const demo = new Demo(7, 4);

const result = demo.add(11).verify()
assert(result.success);

测试合约

接下来我们用 scryptlib 编写合约的单元测试,以确保合约在上线部署之前能够按预期工作。 通过sCrypt 测试框架,我们可以模拟调用 move() 并断言游戏状态。

it('One full round where Alice wins', () => {

    // Alice places an X at 0-th cell
    testMove(true, 0, moveScript(false, [1,0,0,0,0,0,0,0,0]))

    // Bob places an O at 4-th cell
    testMove(false, 4, moveScript(true, [1,0,0,0,2,0,0,0,0]))

    // Alice places an X at 1-th cell
    testMove(true, 1, moveScript(false, [1,1,0,0,2,0,0,0,0]))

    // Bob places an O at 8-th cell
    testMove(false, 8, moveScript(true, [1,1,0,0,2,0,0,0,2]))

    // Alice places an X at 2-th cell and wins
    testMoveWin(true, 2, bsv.Script.buildPublicKeyHashOut(privateKeyAlice.toAddress()));
});
tictactoe.scrypttest.js

集成 Web App

我们假设你已经具备前端开发的基础知识,因此我们不会花时间来介绍这些技术的基础知识。我们将专注于集成智能合约的部分。

编译合约

  1. 通过 IDE 将 TicTacToe 进行编译,得到合约描述文件 tictactoe_release_desc.json。并将其拷贝到 public 目录中,以便我们的能从前端页面加载到该文件。

  2. 在前端页面使用 fetchContract 函数加载合约描述文件 tictactoe_release_desc.json,并实例化合约对象。将合约对象保存在状态中。

async function fetchContract(alicePubKey, bobPubKey) {
  let { contractClass: TictactoeContractClass } = await web3.loadContract(
    "/tic-tac-toe/tictactoe_release_desc.json"
  );

  return new TictactoeContractClass(
    new PubKey(alicePubKey),
    new PubKey(bobPubKey),
    true,
    [0,0,0,0,0,0,0,0,0]
  );
}

...

const instance = await fetchContract(PlayerPublicKey.get(Player.Alice),
    PlayerPublicKey.get(Player.Bob))

updateStates({
    ...
    instance: instance
})

集成钱包

将合约对象 instance 部署到 BSV 网络需要 BSV 。为此我们需要先接入钱包用来获取 BSV 。这里以 sensilet 为例,介绍如何接入钱包。

1. 钱包实现

我们在 wallet.ts 中定义了一些通用的钱包接口。并使用 sensilet 来实现这些接口。具体实现见: sensiletwallet.ts

2. 钱包初始化

App 加载时, 使用 useEffect 来初始化钱包。首先,为 web3 设置一个 SensiletWallet 钱包。然后调用 web3.wallet.isConnected() 将钱包是否连接的状态保存起来。

useEffect(async () => {
  const timer = setTimeout(async ()=> {
      //set a SensiletWallet for web3
      web3.setWallet(new SensiletWallet());
      const isConnected = await web3.wallet.isConnected();

      ...

      updateStates({
          ...
          isConnected: isConnected,
      })

  }, 100)

  return () => {
    clearTimeout(timer)
  }
}, []);

App 的渲染代码中,通过判断 states.isConnected 状态来决定渲染钱包登入组件 Auth 还是钱包余额组件 Balance

return (
    <div className="App">
      <header className="App-header">
        <h2>Play Tic-Tac-Toe on Bitcoin</h2>
        ...
        {states.isConnected ? <Balance></Balance> : <Auth></Auth>}
      </header>
    </div>
  );

3. 钱包登入

下面是实现钱包登入的组件 Auth。用户点击 Sensilet 按钮则调用钱包的 requestAccount 接口来登入钱包。钱包插件会出现授权提示框。

在这里插入图片描述

import { web3 } from "./web3";

const Auth = (props) => {

  const sensiletLogin = async (e) => {
    try {
      const res = await web3.wallet.requestAccount("tic-tac-toe");
      if (res) {
        window.location.reload();
      }
    } catch (error) {
      console.error("requestAccount error", error);
    }
  };

  return (
    <div className="auth">
      <div>
        <button
          className="pure-button button-large sensilet"
          onClick={sensiletLogin}
        >
          Sensilet
        </button>
      </div>
    </div>
  );
};

export default Auth;

4. 钱包余额

Balance 组件调用了钱包的 getbalance 接口,实现了展示钱包余额的功能。

import { useState, useEffect } from "react";
import { web3 } from "./web3";
const Balance = (props) => {
  const [balance, setBalance] = useState(0);

  useEffect(async () => {
    if (web3.wallet) {
      web3.wallet.getbalance().then((balance) => {
        setBalance(balance);
      });
    }
  }, []);

    return (
      <div className="wallet">
        <div className="walletInfo">
          <div className="balance">
            <label>Balance: {balance} <span> (satoshis)</span></label>
          </div>
        </div>
      </div>
    );
};

export default Balance;

接入完钱包后,就可以开始部署合约了。

部署合约

点击 Start 按钮开始游戏时,会回调 App 中的 startGame 方法。该函数实现了将合约实例部署到 BSV 网络上的功能。部署成功后,将包含合约的UTXO到和游戏初始状态保存到 localStorage,并更新 React 状态。

const startGame = async (amount) => {

    if (web3.wallet && states.instance) {

        web3.deploy(states.instance, amount).then(rawTx => {
            //initial game states
            let gameStates = {
                amount: amount,
                name: "tic-tac-toe",
                date: new Date(),
                history: [
                    {
                    squares: Array(9).fill(null),
                    },
                ],
                currentStepNumber: 0,
                isAliceTurn: true,
            };

            //save utxo
            ContractUtxos.add(rawTx);
            //save Game data
            GameData.set(gameStates);
            //first player is Alice
            CurrentPlayer.set(Player.Alice);
            //update states.started
            updateStates(Object.assign({}, states, {
                started: true
            }))
      })
    }
};

其中 web3.deploy() 函数是对钱包接口的封装。主要包含以下步骤:

  1. 调用钱包的 listUnspent 接口,查询可用的 UTXO 来支付部署交易的费用。
  2. 使用链式 APIs 构建包含合约实例 contract 的交易
  3. 调用钱包的 signRawTransaction 接口对于交易进行签名
  4. 最后调用 web3.sendRawTx 广播交易

调用钱包的 signRawTransaction 接口需要用户授权。

在这里插入图片描述

static async deploy(contract: AbstractContract, amountInContract: number): Promise<string> {
    const wallet = web3.wallet

    const changeAddress = await web3.wallet.getRawChangeAddress();

    return wallet.listUnspent(amountInContract, {
      purpose: 'tic-tac-toe'
    }).then((utxos: UTXO[]) => {
      const tx = new bsv.Transaction();
      tx.from([utxos[0]])
        .addOutput(new bsv.Transaction.Output({
          script: contract.lockingScript,
          satoshis: amountInContract,
        }))
        .change(changeAddress);

      return wallet.signRawTransaction(tx.toString(), utxos[0].script, utxos[0].satoshis, 0, SignType.ALL);
    }).then(async (rawTx: string) => {
      await web3.sendRawTx(rawTx);
      return rawTx;
    })
}

在这里插入图片描述

部署成功

部署成功后,就可以开始游戏了。

调用合约

接下来就是开始下棋了,每下一步棋,就是对合约的一次调用,并触发合约状态的改变。Web 应用程序与合约的交互主要发生在这个阶段。

和部署合约一样,我们通过 web3 工具类提供的 web3.call() 来调用合约。

web3.call() 第一个参数是包含合约实例的 UTXO,作为构建调用合约的交易的第一个输入。第二个参数是一个回调函数。我们在
回调函数中使用链式 APIs 来构建完整的调用合约的交易。

调用合约需要完成以下工作:

  1. 从存储中取出包含合约实例的最新的 UTXO。作为交易的输入。
  2. 根据游戏的状态和游戏规则来给交易添加输出。添加输出的过程中, 使用 toContractState() 函数将游戏状态转换成合约状态。

let winner = calculateWinner(squares).winner;

if (winner) { // Current Player won
  let address = PlayerAddress.get(CurrentPlayer.get());

  tx.setOutput(0, (tx) => {
    return new bsv.Transaction.Output({
      script: bsv.Script.buildPublicKeyHashOut(address),
      satoshis: contractUtxo.satoshis - tx.getEstimateFee(),
    })
  })

} else if (history.length >= 9) { //board is full

  tx.setOutput(0, (tx) => {
    return new bsv.Transaction.Output({
      script: bsv.Script.buildPublicKeyHashOut(PlayerAddress.get(Player.Alice)),
      satoshis: (contractUtxo.satoshis - tx.getEstimateFee()) /2,
    })
  })
  .setOutput(1, (tx) => {
    return new bsv.Transaction.Output({
      script: bsv.Script.buildPublicKeyHashOut(PlayerAddress.get(Player.Bob)),
      satoshis: (contractUtxo.satoshis - tx.getEstimateFee()) /2,
    })
  })

} else { //continue move

  const newStates = toContractState(gameState);
  const newLockingScript = this.props.contractInstance.getNewStateScript(newStates);
  tx.setOutput(0, (tx) => {
    const amount = contractUtxo.satoshis - tx.getEstimateFee();
    return new bsv.Transaction.Output({
      script: newLockingScript,
      satoshis: amount,
    })
  })
}
  1. 设置合约解锁脚本。
tx.setInputScript(0, (tx, output) => {

  const preimage = getPreimage(tx, output.script, output.satoshis)
  const privateKey = new bsv.PrivateKey.fromWIF(PlayerPrivkey.get(CurrentPlayer.get()));
  const sig = signTx(tx, privateKey, output.script, output.satoshis)
  const amount = contractUtxo.satoshis - tx.getEstimateFee();

  return this.props.contractInstance.move(i, sig, amount, preimage).toScript();
})
.seal()
  1. 使用钱包提供的 sendRawTransaction 接口广播交易。这封装在 web3.call() 中。

  2. 广播成功后,需要保存调用的交易和包含合约实例的UTXO, 作为下一次调用的输入。 同时还需要更新游戏状态和合约实例的状态。

const utxo = ContractUtxos.add(rawTx); // save latest utxo
GameData.update(gameState); //update game's states
this.attachState(); //update stateful contract's states

至此,我们完成了 TicTacToe 合约与 webapp 的交互,玩家的每个下棋动作,都产生一个区块链上的交易与之对应。

总结

恭喜你! 您刚刚在 BSV 上构建了第一个全栈 dApp。 现在,您可以玩井字游戏或在 BSV 上构建您自己喜欢的游戏。现在是时候喝些香槟了,或者打开下方连接和小伙伴来一场比赛!


[1]: 本文演示的游戏可以在 这里 试玩

[2]: 本文使用的所有代码均源自这个 Github Repo ,欢迎大家加星收藏。

相关推荐

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
©️2022 CSDN 皮肤主题:1024 设计师:我叫白小胖 返回首页
评论 5

打赏作者

sCrypt 智能合约

你的鼓励将是我创作的最大动力

¥2 ¥4 ¥6 ¥10 ¥20
输入1-500的整数
余额支付 (余额:-- )
扫码支付
扫码支付:¥2
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值