Getting your Trinity Audio player ready... |
This post was first published on Medium.
In this guide, you’ll learn a Web3 tech stack that will allow you to build full stack decentralized apps on the Bitcoin SV blockchain. We will walk through the entire process of building a full stack decentralized Tic-Tac-Toe, including:
- Write a smart contract.
- Deploy the contract
- Add a front-end (React)
- Integrate Yours wallet
By the end, you will have a fully functional Tic-Tac-Toe App running on Bitcoin.
What we will use
Let’s go over the main pieces we will be using and how they fit into the stack.
1. sCrypt Framework
sCrypt is a TypeScript framework to develop smart contracts on Bitcoin. It offers a complete tech stack:
- the sCrypt language: an embedded Domain Specific Language (eDSL) based on TypeScript, which allows developers to write smart contracts directly in TypeScript. Developers don’t have to learn a new Web3 programming language like Solidity, and can reuse their favorite tools, like IDEs and NPM.
- library (scrypt-ts): a comprehensive and concise library designed for client-side JavaScript applications, such as React, Vue, Angular, or Svelte, to interact with the Bitcoin SV Blockchain and its ecosystem.
- sCrypt CLI: CLI to easily create, compile and publish sCrypt projects. The CLI provides best practice project scaffolding.
2. Yours Wallet
Yours Wallet is an open-source digital wallet for BSV and 1Sat Ordinals that enables access to decentralized applications developed on Bitcoin SV. Yours Wallet generates and manages private keys for its users in a non-custodial manner, ensuring that the users have full control over their funds and transactions. These keys can be utilized within the wallet to securely store funds and authorize transactions.
3. React
React.js, often simply referred to as React, is a JavaScript library developed by Facebook. It’s primarily used for building user interfaces (UIs) for web applications. It simplifies the process of building dynamic and interactive web applications and continues to be seemingly dominating the front-end space.
What we will Build
We will build a very simple Tic-Tac-Toe game on chain. It uses the Bitcoin addresses of two players (Alice and Bob respectively) to initialize a smart contract. They each bet the same amount and lock it into the contract. The winner takes all bitcoins locked in the contract. If no one wins and there is a draw, the two players can each withdraw half of the money. Tic-Tac-Toe, the age-old game of strategy and skill, has now found its way onto the blockchain thanks to the power of sCrypt.
Prerequisites
- Install node.js and npm (node.js ≥ version 16)
- Install Git
- Yours wallet Chrome extension installed in your browser
- Install sCrypt CLI
npm install -g scrypt-cli
Getting Started
Let’s simply create a new React project.
Firstly let’s create a new React project with TypeScript template.
npx create-react-app tic-tac-toe --template typescript
Then, change the directory to the tic-tac-toe project directory and also run the init command of the CLI to add sCrypt support in your project.
cd tic-tac-toe npx scrypt-cli@latest init
Tic-tac-toe Contract
Next, let’s create a contract at src/contracts/tictactoe.ts:
import { prop, method, SmartContract, PubKey, FixedArray, assert, Sig, Utils, toByteString, hash160, hash256, fill, ContractTransaction, MethodCallOptions, bsv } from "scrypt-ts"; export class TicTacToe extends SmartContract { @prop() alice: PubKey; @prop() bob: PubKey; @prop(true) isAliceTurn: boolean; @prop(true) board: FixedArray<bigint, 9>; static readonly EMPTY: bigint = 0n; static readonly ALICE: bigint = 1n; static readonly BOB: bigint = 2n; constructor(alice: PubKey, bob: PubKey) { super(...arguments) this.alice = alice; this.bob = bob; this.isAliceTurn = true; this.board = fill(TicTacToe.EMPTY, 9); } @method() public move(n: bigint, sig: Sig) { // check position `n` assert(n >= 0n && n < 9n); // check signature `sig` let player: PubKey = this.isAliceTurn ? this.alice : this.bob; assert(this.checkSig(sig, player), `checkSig failed, pubkey: ${player}`); // update stateful properties to make the move assert(this.board[Number(n)] === TicTacToe.EMPTY, `board at position ${n} is not empty: ${this.board[Number(n)]}`); let play = this.isAliceTurn ? TicTacToe.ALICE : TicTacToe.BOB; this.board[Number(n)] = play; this.isAliceTurn = !this.isAliceTurn; // build the transation outputs let outputs = toByteString(''); if (this.won(play)) { outputs = Utils.buildPublicKeyHashOutput(hash160(player), this.ctx.utxo.value); } else if (this.full()) { const halfAmount = this.ctx.utxo.value / 2n; const aliceOutput = Utils.buildPublicKeyHashOutput(hash160(this.alice), halfAmount); const bobOutput = Utils.buildPublicKeyHashOutput(hash160(this.bob), halfAmount); outputs = aliceOutput + bobOutput; } else { // build a output that contains latest contract state. outputs = this.buildStateOutput(this.ctx.utxo.value); } if (this.changeAmount > 0n) { outputs += this.buildChangeOutput(); } // make sure the transaction contains the expected outputs built above assert(this.ctx.hashOutputs === hash256(outputs), "check hashOutputs failed"); } @method() won(play: bigint): boolean { let lines: FixedArray<FixedArray<bigint, 3>, 8> = [ [0n, 1n, 2n], [3n, 4n, 5n], [6n, 7n, 8n], [0n, 3n, 6n], [1n, 4n, 7n], [2n, 5n, 8n], [0n, 4n, 8n], [2n, 4n, 6n] ]; let anyLine = false; for (let i = 0; i < 8; i++) { let line = true; for (let j = 0; j < 3; j++) { line = line && this.board[Number(lines[i][j])] === play; } anyLine = anyLine || line; } return anyLine; } @method() full(): boolean { let full = true; for (let i = 0; i < 9; i++) { full = full && this.board[i] !== TicTacToe.EMPTY; } return full; } }
Properties
The Tic-Tac-Toe contract features several essential properties that define its functionality:
- Alice and Bob: Public keys of the two players.
- is_alice_turn: A boolean flag indicating whose turn it is to play.
- board: A representation of the game board, stored as a fixed-size array.
- Constants: Three static properties defining game symbols and empty squares.
Constructor
constructor(alice: PubKey, bob: PubKey) { super(...arguments) this.alice = alice; this.bob = bob; this.isAliceTurn = true; this.board = fill(TicTacToe.EMPTY, 9); }
Upon deployment, the constructor initializes the contract with the public keys of Alice and Bob. Additionally, it sets up an empty game board to kickstart the game play.
Public methods
Each contract must have at least one public @method. It is denoted with the public modifier and does not return any value. It is visible outside the contract and acts as the main method into the contract (like main in C and Java).
The public method in the contract is move(), which allows players to make their moves on the board. This method validates the moves, checks the player’s signature, updates the game state, and determines the outcome of the game.
Signature verification
Once the game contract is deployed, anyone can view and potentially interact with it. We need an authentication mechanism to ensure only the desired player can update the contract if it’s their turn. This is achieved using digital signatures.
Only the authorized player can make a move during their turn, validated through their respective public key stored in the contract.
// check signature `sig` let player: PubKey = this.isAliceTurn ? this.alice : this.bob; assert(this.checkSig(sig, player), `checkSig failed, pubkey: ${player}`);
Non-Public methods
The contract includes two non-public methods, won() and full(), responsible for determining whether a player has won the game and if the board is full, leading to a draw.
Tx Builder: buildTxForMove
Bitcoin transaction can have multiple inputs and outputs. We need to build a transaction when calling a contract.
Here, we have implement a customize transaction builder for the move() method as below:
static buildTxForMove( current: TicTacToe, options: MethodCallOptions<TicTacToe>, n: bigint ): Promise<ContractTransaction> { const play = current.isAliceTurn ? TicTacToe.ALICE : TicTacToe.BOB; const nextInstance = current.next(); nextInstance.board[Number(n)] = play; nextInstance.isAliceTurn = !current.isAliceTurn; const unsignedTx: bsv.Transaction = new bsv.Transaction().addInput( current.buildContractInput(options.fromUTXO) ); if (nextInstance.won(play)) { const script = Utils.buildPublicKeyHashScript( hash160(current.isAliceTurn ? current.alice : current.bob) ); unsignedTx.addOutput( new bsv.Transaction.Output({ script: bsv.Script.fromHex(script), satoshis: current.balance, }) ); if (options.changeAddress) { unsignedTx.change(options.changeAddress); } return Promise.resolve({ tx: unsignedTx, atInputIndex: 0, nexts: [], }); } if (nextInstance.full()) { const halfAmount = current.balance / 2; unsignedTx .addOutput( new bsv.Transaction.Output({ script: bsv.Script.fromHex( Utils.buildPublicKeyHashScript(hash160(current.alice)) ), satoshis: halfAmount, }) ) .addOutput( new bsv.Transaction.Output({ script: bsv.Script.fromHex( Utils.buildPublicKeyHashScript(hash160(current.bob)) ), satoshis: halfAmount, }) ); if (options.changeAddress) { unsignedTx.change(options.changeAddress); } return Promise.resolve({ tx: unsignedTx, atInputIndex: 0, nexts: [], }); } unsignedTx.setOutput(0, () => { return new bsv.Transaction.Output({ script: nextInstance.lockingScript, satoshis: current.balance, }); }); if (options.changeAddress) { unsignedTx.change(options.changeAddress); } const nexts = [ { instance: nextInstance, atOutputIndex: 0, balance: current.balance, }, ]; return Promise.resolve({ tx: unsignedTx, atInputIndex: 0, nexts, next: nexts[0], }); }
Integrate Front-end (React)
After we have written/tested our contract, we can integrate it with front-end so that users can play our game.
First, let’s compile the contract and get the contract artifact json file by running the command below:
npx scrypt-cli@latest compile
You should see an artifact file tictactoe.json in the artifacts directory. It can be used to initialize a contract at the front end.
import { TicTacToe } from './contracts/tictactoe'; import artifact from '../artifacts/tictactoe.json'; TicTacToe.loadArtifact(artifact);
Install and Fund Wallet
Before deploying a contract, we need to connect a wallet first. We use Yours Wallet, a MetaMask-like wallet.
After installing the wallet, click the settings button in the upper right corner to switch to testnet. Then copy your wallet address and go to our faucet to fund it.
Connect to wallet
We call requestAuth() to request to connect to the wallet. If the request is approved by the user, we now have full access to the wallet. We can, for example, call getDefaultPubKey() to get its public key.
const walletLogin = async () => { try { const provider = new DefaultProvider({ network: bsv.Networks.testnet }); const signer = new PandaSigner(provider); signerRef.current = signer; const { isAuthenticated, error } = await signer.requestAuth() if (!isAuthenticated) { throw new Error(error) } setConnected(true); const alicPubkey = await signer.getDefaultPubKey(); setAlicePubkey(toHex(alicPubkey)) // Prompt user to switch accounts } catch (error) { console.error("pandaLogin failed", error); alert("pandaLogin failed") } };
Initialize the contract
We have obtained the contract class Tictactoe by loading the contract artifact file. When a user clicks the start button, the contract is initialized with the public keys of two players alice and bob. The public key can be obtained through calling etDefaultPubKey()of Signer.
The following code initializes the contract.
const [alicePubkey, setAlicePubkey] = useState(""); const [bobPubkey, setBobPubkey] = useState(""); ... const startGame = async (amount: number) => { try { const signer = signerRef.current as PandaSigner; const instance = new TicTacToe( PubKey(toHex(alicePubkey)), PubKey(toHex(bobPubkey)) ); await instance.connect(signer); } catch(e) { console.error('deploy TicTacToe failes', e) alert('deploy TicTacToe failes') } };
Call the contract
Now we can start playing the game. Every move is a call to the contract and triggers a change in the state of the contract.
const { tx: callTx } = await p2pkh.methods.unlock( (sigResponses: SignatureResponse[]) => findSig(sigResponses, $publickey), $publickey, { pubKeyOrAddrToSign: $publickey.toAddress() } as MethodCallOptions<P2PKH> );
After finishing with the front-end you can simply run :
npm start
You can now view it at `http://localhost:3000/` in your browser.
Conclusion
Congratulations! You have just built your first full stack dApp on Bitcoin. Now you can play tic-tac-toe or build your favorite game on Bitcoin. Now would be a good time to pop some champagne if you haven’t already :).
One session of play can be viewed here:
All the code can be found at this github repo.
By default, we deploy the contract on testnet. You can easily change it to mainnet.
Watch: sCrypt Hackathon 2024 (17th March 2024, PM)