Tutorial: Building A Dice Game Contract With Hive Stream (Part 2)
If you missed [part one here](https://peakd.com/hive-139531/@beggars/tutorial-building-a-dice-game-contract-with-hive-stream), we built a basic dice game contract which allows people to bet on an outcome and either win or lose. The tutorial left out some things that you might want to do in a real deployment.
## Install MongoDB
For this tutorial, we are going to use MongoDB as the database behind it. We'll be storing transactions and outcomes in the database, so we can ensure that transfers are not processed multiple times.
While the streamer keeps track of the last processed block number in a JSON file or SQLite database, what happens if your node goes down and the file gets edited or deleted? It would result in missed transactions which would be a pain to manually process.
Download the community version installer from the official [MongoDB](https://www.mongodb.com/download-center/community) website. Choose the appropriate installer for your operating system and follow the instructions. It should require very little input, just keep clicking next basically and keep everything as default.
## Build a MongoDB adapter
Now, we need to write an adapter that will allow Hive Stream to work with MongoDB instead of the file databases that it is configured out-of-the-box to use. Provided you have Hive Stream installed (version 2+) you should have the new adapter's functionality.
An adapter for standard use cases consists of just two mandatory methods `loadState` and `saveState` the `loadState` method gets called when the streamer starts to start at the last block it finished. The `saveState` method updates with the latest processed block.
There are however other lifecycle methods which we can use in our more functional adapters. `create` is called on initialisation, you can setup instances and connections in here. `destroy` is called when everything is stopped, so naturally you close database connections in here.
The `processOperation` is a method that gets the latest block number, transaction ID and so forth. It's metadata used to identify specific transactions and their block data. The `processTransfer` and `processCustomJson` methods are called when a matching transfer or custom JSON operation is matched to a contract. It also gets contract information as well as the payload and other helpful info.
**Save the following as `mongo.adapter.js`**
```
import { AdapterBase } from 'hive-stream';
import { MongoClient, Db } from 'mongodb';
export class MongodbAdapter extends AdapterBase {
client;
db;
mongo = {
uri: '',
database: '',
options: {}
};
blockNumber;
lastBlockNumber;
blockId;
prevBlockId;
transactionId;
constructor(uri, databasw, options = { useNewUrlParser: true, useUnifiedTopology: true }) {
super();
this.mongo.uri = uri;
this.mongo.database = database;
this.mongo.options = options;
}
async create() {
try {
this.client = await MongoClient.connect(this.mongo.uri, this.mongo.options);
this.db = this.client.db(this.mongo.database);
return true;
} catch (e) {
throw e;
}
}
async loadState() {
try {
const collection = this.db.collection('params');
const params = await collection.findOne({});
if (params) {
return params;
}
} catch (e) {
throw e;
}
}
async saveState(data) {
try {
const collection = this.db.collection('params');
await collection.replaceOne({}, data, { upsert: true});
return true;
} catch (e) {
throw e;
}
}
async processOperation(op, blockNumber, blockId, prevBlockId, trxId, blockTime) {
this.blockNumber = blockNumber;
this.blockId = blockId;
this.prevBlockId = prevBlockId;
this.transactionId = trxId;
}
async processTransfer(operation, payload, metadata) {
const collection = this.db.collection('transfers');
const data = {
id: this.transactionId,
blockId: this.blockId,
blockNumber: this.blockNumber,
sender: metadata.sender,
amount: metadata.amount,
contractName: payload.name,
contractAction: payload.action,
contractPayload: payload.payload
};
await collection.insertOne(data);
return true;
}
async processCustomJson(operation, payload, metadata) {
const collection = this.db.collection('transactions');
const data = {
id: this.transactionId,
blockId: this.blockId,
blockNumber: this.blockNumber,
sender: metadata.sender,
isSignedWithActiveKey: metadata.isSignedWithActiveKey,
contractName: payload.name,
contractAction: payload.action,
contractPayload: payload.payload
};
await collection.insertOne(data);
return true;
}
async destroy() {
await this.client.close();
return true;
}
}
```
## Expanding the dice contract
Now we have MongoDB support added via our adapter, let's modify the contract code to flag whether or not a transfer was processed (winnings sent or loss memo sent). This will allow us to replay our dice game and not worry about paying out users who already received their winnings multiple times.
Above the `if (verify)` line of code, let's access the Mongo database client and connection.
```
const db = this._instance['adapter']['db'];
const collection = db.collection('transfers');
```
Because contracts get access to the streamer instance, it means we can access the adapter and `db` property. The downside here is for any adapter that doesn't have a `db` property, this will fail. In our case, we know we want to use the database adapter, so the slightly tighter coupling is fine.
Inside of the balance check that refunds if the account doesn't have more than the maximum bet, we'll add some code that sets the status to `refund`
Put the following inside of this if statement: ``if (balance < MAX_BET) {`` underneath the `transferHiveTokens` call.
```
await collection.findOneAndUpdate({ id: this.transactionId }, { $set: { status: 'refund' } });
```
What we are doing here is querying the database for our transaction based on its ID, then using the MongoDB `$set` property to only add/update one specific property in the `transfers` collection.
Next, inside of the if statement ``if (parseFloat(tokensWon) > balance) {`` add in the same line of code beneath the `transferHiveTokens` call:
```
await collection.findOneAndUpdate({ id: this.transactionId }, { $set: { status: 'refund' } });
```
Underneath the transfer code for winnings ``await this._instance.transferHiveTokens(ACCOUNT, sender, tokensWon, TOKEN_SYMBOL, winningMemo);`` add in the following:
```
await collection.findOneAndUpdate({ id: this.transactionId }, { $set: { status: 'win' } });
```
And now for the loss inside of the else statement beneath ``await this._instance.transferHiveTokens(ACCOUNT, sender, '0.001', TOKEN_SYMBOL, losingMemo);``
```
await collection.findOneAndUpdate({ id: this.transactionId }, { $set: { status: 'loss' } });
```
Finally, another underneath ``await this._instance.transferHiveTokens(ACCOUNT, sender, amountTrim[0], amountTrim[1], `[Refund] Invalid bet params.`);`` which refunds the user if their bet amount is higher than the max, their dice roll is too high or low.
```
await collection.findOneAndUpdate({ id: this.transactionId }, { $set: { status: 'refund' } });
```
**The final dice contract code ends up looking like this:**
```
import { Utils } from 'hive-stream';
import seedrandom from 'seedrandom';
import BigNumber from 'bignumber.js';
const CONTRACT_NAME = 'hivedice';
const ACCOUNT = 'beggars';
const TOKEN_SYMBOL = 'HIVE';
const HOUSE_EDGE = 0.05;
const MIN_BET = 1;
const MAX_BET = 10;
// Random Number Generator
const rng = (previousBlockId, blockId, transactionId) => {
const random = seedrandom(`${previousBlockId}${blockId}${transactionId}`).double();
const randomRoll = Math.floor(random * 100) + 1;
return randomRoll;
};
// Valid betting currencies
const VALID_CURRENCIES = ['HIVE'];
class DiceContract {
_instancs;
blockNumber;
blockId;
previousBlockId;
transactionId;
create() {
// Runs every time register is called on this contract
// Do setup logic and code in here (creating a database, etc)
}
destroy() {
// Runs every time unregister is run for this contract
// Close database connections, write to a database with state, etc
}
// Updates the contract with information about the current block
// This is a method automatically called if it exists
updateBlockInfo(blockNumber, blockId, previousBlockId, transactionId) {
// Lifecycle method which sets block info
this.blockNumber = blockNumber;
this.blockId = blockId;
this.previousBlockId = previousBlockId;
this.transactionId = transactionId;
}
/**
* Get Balance
*
* Helper method for getting the contract account balance. In the case of our dice contract
* we want to make sure the account has enough money to pay out any bets
*
* @returns number
*/
async getBalance() {
const account = await this._instance['client'].database.getAccounts([ACCOUNT]);
if (account?.[0]) {
const balance = (account[0].balance as string).split(' ');
const amount = balance[0];
return parseFloat(amount);
}
return null;
}
/**
* Roll
*
* Automatically called when a custom JSON action matches the following method
*
* @param payload
* @param param1 - sender and amount
*/
async roll(payload, { sender, amount }) {
// Destructure the values from the payload
const { roll } = payload;
// The amount is formatted like 100 HIVE
// The value is the first part, the currency symbol is the second
const amountTrim = amount.split(' ');
// Parse the numeric value as a real value
const amountParsed = parseFloat(amountTrim[0]);
// Format the amount to 3 decimal places
const amountFormatted = parseFloat(amountTrim[0]).toFixed(3);
// Trim any space from the currency symbol
const amountCurrency = amountTrim[1].trim();
console.log(`Roll: ${roll}
Amount parsed: ${amountParsed}
Amount formatted: ${amountFormatted}
Currency: ${amountCurrency}`);
// Get the transaction from the blockchain
const transaction = await this._instance.getTransaction(this.blockNumber, this.transactionId);
// Call the verifyTransfer method to confirm the transfer happened
const verify = await this._instance.verifyTransfer(transaction, sender, 'beggars', amount);
// Get the balance of our contract account
const balance = await this.getBalance();
const db = this._instance['adapter']['db'];
const collection = db.collection('transfers');
// Transfer is valid
if (verify) {
// Server balance is less than the max bet, cancel and refund
if (balance < MAX_BET) {
// Send back what was sent, the server is broke
await this._instance.transferHiveTokens(ACCOUNT, sender, amountTrim[0], amountTrim[1], `[Refund] The server could not fufill your bet.`);
await collection.findOneAndUpdate({ id: this.transactionId }, { $set: { status: 'refund' } });
return;
}
// Bet amount is valid
if (amountParsed >= MIN_BET && amountParsed <= MAX_BET) {
// Validate roll is valid
if ((roll >= 2 && roll <= 96) && VALID_CURRENCIES.includes(amountCurrency)) {
// Roll a random value
const random = rng(this.previousBlockId, this.blockId, this.transactionId);
// Calculate the multiplier percentage
const multiplier = new BigNumber(1).minus(HOUSE_EDGE).multipliedBy(100).dividedBy(roll);
// Calculate the number of tokens won
const tokensWon = new BigNumber(amountParsed).multipliedBy(multiplier).toFixed(3, BigNumber.ROUND_DOWN);
// Memo that shows in users memo when they win
const winningMemo = `You won ${tokensWon} ${TOKEN_SYMBOL}. Roll: ${random}, Your guess: ${roll}`;
// Memo that shows in users memo when they lose
const losingMemo = `You lost ${amountParsed} ${TOKEN_SYMBOL}. Roll: ${random}, Your guess: ${roll}`;
// User won more than the server can afford, refund the bet amount
if (parseFloat(tokensWon) > balance) {
await this._instance.transferHiveTokens(ACCOUNT, sender, amountTrim[0], amountTrim[1], `[Refund] The server could not fufill your bet.`);
await collection.findOneAndUpdate({ id: this.transactionId }, { $set: { status: 'refund' } });
return;
}
// If random value is less than roll
if (random < roll) {
await this._instance.transferHiveTokens(ACCOUNT, sender, tokensWon, TOKEN_SYMBOL, winningMemo);
await collection.findOneAndUpdate({ id: this.transactionId }, { $set: { status: 'win' } });
} else {
await this._instance.transferHiveTokens(ACCOUNT, sender, '0.001', TOKEN_SYMBOL, losingMemo);
await collection.findOneAndUpdate({ id: this.transactionId }, { $set: { status: 'loss' } });
}
} else {
// Invalid bet parameters, refund the user their bet
await this._instance.transferHiveTokens(ACCOUNT, sender, amountTrim[0], amountTrim[1], `[Refund] Invalid bet params.`);
await collection.findOneAndUpdate({ id: this.transactionId }, { $set: { status: 'refund' } });
}
} else {
try {
// We need to refund the user
const transfer = await this._instance.transferHiveTokens(ACCOUNT, sender, amountTrim[0], amountTrim[1], `[Refund] You sent an invalid bet amount.`);
await collection.findOneAndUpdate({ id: this.transactionId }, { $set: { status: 'refund' } });
} catch (e) {
console.log(e);
}
}
}
}
}
export default new DiceContract();
```
## Register your adapter, run it
We take the code we bootstrapped our app with from part one and it remains largely untouched, except we register our MongoDB adapter and pass in some configuration information to the constructor.
```
import { Streamer } from 'hive-stream';
import DiceContract from './dice.contract';
import { MongodbAdapter } from './mongo.adapter';
const streamer = new Streamer({
ACTIVE_KEY: '', // Needed for transfers
JSON_ID: 'testdice' // Identifier in the custom JSON payloads
});
streamer.registerAdapter(new MongodbAdapter('mongodb://127.0.0.1:27017', 'hivestream'));
// Register the contract
streamer.registerContract('hivedice', DiceContract);
// Starts the streamer watching the blockchain
streamer.start();
```
![Screen Shot 20200406 at 8.14.42 pm.png](https://files.peakd.com/file/peakd-hive/beggars/UpZgH9Fl-Screen20Shot202020-04-0620at208.14.4220pm.png)
Here is a screenshot of a modified transaction that was refunded updated in our Mongo database to show that it all works as intended.
## Conclusion
What did we learn? We learned how to write a custom adapter for interfacing with a database, we learned how to access to the database instance inside of our contract and interact with it. We also learned we can then look up transactions by their ID from within contract actions and modify them (in our case, setting a status property).
PS. The Mongo adapter ships with the Hive Stream package, so you don't have to custom code it yourself, I built it as part of the process of this tutorial. Enjoy.
See: Tutorial: Building A Dice Game Contract With Hive Stream (Part 2) by @beggars