在我们之前的博客中,介绍了如何使用 sCrypt 来编写 BSV 智能合约。但是作为刚入门的开发者,你可能对如何使用 sCrypt 来构建 dApp 更加感兴趣。接下来我们将教大家如何使用 sCrypt 一步一步地构建一个井字棋 dApp.
该应用程序非常简单,它所做的就是使用两个玩家(分别是 Alice 和 Bob)的公钥,初始化合约,只有赢得那玩家可以取走合约里面的 bsv。如果最后没有人赢,则两个玩家各自可以取走一半。我们将向您展示如何在 BSV 区块链上构建去中心化应用程序(又名 dApp),包括:
- 编写合约
- 测试合约
- 集成一个简单的 Web 应用程序与合约进行交互
到最后,你将拥有一个运行在 BSV 上的井字棋游戏dApp。
搭建开发环境
- 安装 sCrypt IDE,见 sCrypt 开发工具篇 - Visual Studio Code 插件
- 安装 nodejs, version >= 12
- 安装 Typescript
使用 Git 克隆 React App 项目 tic-tac-toe, 并切换到的 webapp
分支。 该分支包含一个只有前端代码的井字棋游戏。然后在根目录下创建一个 contracts
和 test
目录,分别用来存放合约代码和合约的测试代码。你将看到以下目录结构。
使用 sCrypt 编写 tic-tac-toe 合约
TicTacToe
合约主要实现原理是通过有状态合约将游戏的状态存储在合约中。井字棋游戏状态由以下组成:
turn
: 布尔类型。表示轮到谁下棋,true
表示轮到 Alice,false
表示轮到 Bobboard
: 整数数组类型。记录棋盘当前的状态,每个元素代表棋盘的一个位置,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;
}
}
游戏规则
首先,通过钱包将一定数量的 BSV 锁定在一个包含上述合约的 UTXO 中。接下来,Alice 和 Bob 通过调用公共函数 move()
交替玩游戏:
- 如果玩家获胜,他/她将拿走合约中锁定的所有资金
- 如果棋盘已满且无人获胜,则为平局,Alice 和 Bob 各拿一半的资金
- 否则,游戏仍在进行中,下一个玩家移动棋子
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()));
});
集成 Web App
我们假设你已经具备前端开发的基础知识,因此我们不会花时间来介绍这些技术的基础知识。我们将专注于集成智能合约的部分。
编译合约
-
通过 IDE 将
TicTacToe
进行编译,得到合约描述文件tictactoe_release_desc.json
。并将其拷贝到public
目录中,以便我们的能从前端页面加载到该文件。 -
在前端页面使用
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() 函数是对钱包接口的封装。主要包含以下步骤:
- 调用钱包的
listUnspent
接口,查询可用的 UTXO 来支付部署交易的费用。 - 使用链式 APIs 构建包含合约实例
contract
的交易 - 调用钱包的
signRawTransaction
接口对于交易进行签名 - 最后调用
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 来构建完整的调用合约的交易。
调用合约需要完成以下工作:
- 从存储中取出包含合约实例的最新的 UTXO。作为交易的输入。
- 根据游戏的状态和游戏规则来给交易添加输出。添加输出的过程中, 使用
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,
})
})
}
- 设置合约解锁脚本。
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()
-
使用钱包提供的
sendRawTransaction
接口广播交易。这封装在web3.call()
中。 -
广播成功后,需要保存调用的交易和包含合约实例的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 ,欢迎大家加星收藏。