You have the checkers CosmJS codebase up to the integrated GUI. If not, follow the previous steps or go ahead and clone and checkout this branch(opens new window) to get the version needed for this tutorial.
Now that your blockchain is complete, you can think about additional data and services that would add value without increasing cost or complexity on-chain.
For example, how do you list all of a player's games? Currently this information is not easily available. You can find the players of a given game, but not the games of a given player. Indexing this on-chain would add storage and computation costs.
As a fast and simple Web 2.0 solution, navigate to the checkers CosmJS repository(opens new window) for the checkers blockchain, in the existing gui branch, and perform the following steps:
Create a sub-directory of the src folder (e.g. server).
Use the express Node.js module to create an HTTP REST API.
Use a local db.json as a database. This is obviously primitive and not thread-safe. In a production setting use a proper database.
Poll the blockchain at regular intervals. As part of an advanced topic, you can use WebSockets.
Not only does this keep information about players, it also keeps a copy of games. This gets around a current limitation of CosmJS, where you cannot get information about a game that has just been erased from the latest state. In practice you would need to query the game at an earlier block height, but this functionality is not yet available with CosmJS. Note that nodes may prune the old state, especially if they migrate, which may unpredictably impact any query at an earlier block height.
State storage is the most expensive resource a blockchain application uses. Wasteful use of storage burdens nodes with unnecessarily large storage requirements.
From an engineering perspective, it is important to separate strict protocol needs from overall requirements. If no state-changing, on-chain logic relies on a stored value, then by definition that value's usefulness is limited to reporting. Such a value is a candidate for deletion from the blockchain state. Reporting is important, but consider more efficient methods of addressing it.
An off-chain indexer - think block explorers - can record historic facts before they are purged. This will support queries about details that are no longer present on the chain. This can also support queries about details that are still present on the chain, but at a much cheaper cost per query than querying blockchain nodes.
Preventing duplication of unique identifiers is a common requirement. The rule can be enforced even if the details are purged, as long as you keep a list of used keys or observed hashes that must not be used again. For example, keep IDs for games that used to exist while deleting the details about the moves and results that have no further relevance to the protocol because the games are "over". If needed, you can keep the complete move-by-move history of the game in cheaper, off-chain storage. This is possible if you designed your chain with proper reporting mechanisms, for instance via events.
In most cases, the most elegant code proceeds on the basis that referential integrity is guaranteed, avoiding messy exceptions like orphaned keys. Internal referential integrity is entirely the responsibility of the application developer, so consider using techniques like cascade deletions or preventing deletion if a record is referenced elsewhere.
Generally, it is a good idea to use deletes to prevent the state from simply growing forever. A simple rule of thumb is to delete everything you can, but no more.
You need to create a client to connect to your checkers blockchain. The client only needs read-only functionality because this server does not submit transactions. Your repository already contains useful elements:
Note how the container is started inside checkers-net alongside the checkers blockchain.
You should see the current height rising:
Copy
Connected to chain-id: checkers-1
server started at http://localhost:3001
2022-04-20T17:46:29.962Z Current heights: 0 <= 1353
2022-04-20T17:46:34.968Z Current heights: 0 <= 1357
To index games, take each block and listen for the following relevant events:
A transaction with a new-game-created event.
An EndBlock with a game-forfeited event.
Start by getting each block from your last saved state. Update poll:
Copy
constpoll=async()=>{const currentHeight =await client.getHeight()if(db.status.block.height <= currentHeight -100)console.log(`Catching up ${db.status.block.height}..${currentHeight}`)while(db.status.block.height < currentHeight){const processing = db.status.block.height +1
process.stdout.cursorTo(0)// Get the blockconst block: Block =await client.getBlock(processing)
process.stdout.write(`Handling block: ${processing} with ${block.txs.length} txs`)// Function yet to be declaredawaithandleBlock(block)
db.status.block.height = processing
}awaitsaveDb()
timer =setTimeout(poll, pollIntervalMs)} src server indexer.ts View source
This needs a new import:
Copy
import{ Block }from"@cosmjs/stargate" src server indexer.ts View source
The indexer:
Declares a new function handleBlock. Create one and put console.log(block) inside to explore what this object is and consider what actions you would take with it.
Saves the db after a poll, so you can watch it in real time.
Uses process.stdout.write and process.stdout.cursorTo(0) so that the repetitive logging happens on a single line.
Observe the relevant content in handleBlock. It must:
Extract the events from transactions.
Extract the events from EndBlock.
If you examine block.txs directly you find transactions as they were posted. However, this does not reveal any execution results, such as if a transaction executed as expected or what game ID it used for the new game. To get this extra information:
Calculate txHash from the transaction.
Call await client.getTx(txHash), which returns an IndexedTx.
The handleBlock function can be:
Copy
consthandleBlock=async(block: Block)=>{if(0< block.txs.length)console.log("")let txIndex =0while(txIndex < block.txs.length){const txHash:string=toHex(sha256(block.txs[txIndex])).toUpperCase()const indexed: IndexedTx |null=await client.getTx(txHash)if(!indexed)thrownewError(`Could not find indexed tx: ${txHash}`)// Function yet to be declaredawaithandleTx(indexed)
txIndex++}// TODO handle EndBlock} src server indexer.ts View source
console.log("") puts a new line (poll does a process.stdout.write which adds no line).
The handleBlock function uses a new function, handleTx. Create one and put console.log(indexed) inside to explore what this object is and consider what actions you can take with it.
The EndBlock part has not yet been incorporated. This is explained in Prepare for EndBlock.
Copy
consthandleTx=async(indexed: IndexedTx)=>{const rawLog:any=JSON.parse(indexed.rawLog)const events: StringEvent[]= rawLog.flatMap((log: ABCIMessageLog)=> log.events)// Function yet to be declaredawaithandleEvents(events)} src server indexer.ts View source
This needs new imports:
Copy
import{ ABCIMessageLog, StringEvent }from"cosmjs-types/cosmos/base/abci/v1beta1/abci" src server indexer.ts View source
The handleTx function uses a new function, handleEvents. Create one and put console.log(events) in it to explore what this object is and consider what actions you can take with it.
Copy
const handleEvents =async(events: StringEvent[]):Promise<void>=>{try{let eventIndex =0while(eventIndex < events.length){// Function yet to be declaredawaithandleEvent(events[eventIndex])
eventIndex++}}catch(e){// Skipping if the handling failed. Most likely the transaction failed.}} src server indexer.ts View source
while() {} simplifies the syntax of awaiting multiple times sequentially.
The handleEvents function only keeps events that emanate from the checkers module.
It uses a new function, handleEvent. Create one and put console.log(event) inside to explore what this object is and consider what actions you can take with it.
It skips in case of error, as the likely cause is that the transactions in fact failed. This is not good enough if the goal is to be absolutely accurate.
Copy
const handleEvent =async(event: StringEvent):Promise<void>=>{if(event.type =="new-game-created"){// Function yet to be declaredawaithandleEventCreate(event)}if(event.type =="move-played"){// Function yet to be declaredawaithandleEventPlay(event)}} src server indexer.ts View source
handleEvent uses two new functions: handleEventCreate and handleEventPlay. Create them and put console.log(event) inside each to explore what these objects are and consider what actions you would take with them.
Nicely formatted EndBlock events are still missing from CosmJS, so these require a little extra work:
To get a block's EndBlock events, you need to ask for the block information from a CometBFT client. This client is a private field(opens new window) of StargateClient.
To handle the call to blockResults, you need access to a CometBFT client. One option is to make a copy of the private CometBFT client. You can do this only on construction, so create a child class of CheckersStargateClient to do that. It is recommended to keep it close by indexer.ts. In a new indexer_stargateclient.ts:
The events that you have converted are compatible with those emanating from transactions, so you can just pass them on. You still need to update handleEvent so that it acts on the new event type:
Copy
const handleEvent =async(event: StringEvent):Promise<void>=>{...+if(event.type =="game-forfeited"){+// Function yet to be declared+awaithandleEventForfeit(event)+}} src server indexer.ts View source
In the standalone checkers in Docker, the deadline is unfortunately set at 24 hours, so feedback is not exactly coming fast. At this state of the exercise, if you want to test the expiry quickly, you will have to run Ignite CLI and adjust the MaxTurnDuration as described here.
In the actions that the Express server exposes, app.patch still remains to be implemented. This allows a user to inform the server that its database is no longer synchronized, and that it should look at a specific game. It is a matter of data re-synchronization:
If the game can be found in the blockchain state, update the indexer's database accordingly:
If there is a winner, then the game should be removed from its players' lists of games.
If there is no winner, then the game should be added to its players' lists of games.
If the game cannot be found in the blockchain state, but is present in the indexer's database, then the game should be removed from the lists of games of its players, and marked as soft-deleted. This shows the usefulness of keeping old games.
If the game cannot be found either in the blockchain state nor in the indexer's database, then it is better not to do anything. To remove it from all players' lists of games is potentially expensive. This could expose the server to a DoS attack.
Code the following:
Copy
const patchGame =async(gameId:string):Promise<boolean>=>{const game: StoredGame |undefined=await client.checkersQueryClient?.checkers.getStoredGame(gameId)const cachedGame: GameInfo |undefined= db.games[gameId]if(!game && cachedGame){console.log(`Patch game: deleted, ${gameId}, black: ${cachedGame.blackAddress}, red: ${cachedGame.redAddress}`,)const blackGames:string[]= db.players[cachedGame.blackAddress]?.gameIds ??[]const redGames:string[]= db.players[cachedGame.redAddress]?.gameIds ??[]const indexInBlack:number= blackGames.indexOf(gameId)if(0<= indexInBlack) blackGames.splice(indexInBlack,1)const indexInRed:number= redGames.indexOf(gameId)if(0<= indexInRed) redGames.splice(indexInRed,1)
cachedGame.deleted =truereturntrue}elseif(!game){// No information to work from.// If we try to remove it from all players, it is very expensive and we are at risk of a DoS attack.console.log(`Patch game: not found, ${gameId}`)returnfalse}elseif(game.winner !=="*"){const blackGames:string[]= db.players[game.black]?.gameIds ??[]const redGames:string[]= db.players[game.red]?.gameIds ??[]console.log(`Patch game: ended, ${gameId}, black: ${game.black}, red: ${game.red}, winner: ${game.winner}`,)const indexInBlack:number= blackGames.indexOf(gameId)if(0<= indexInBlack) blackGames.splice(indexInBlack,1)const indexInRed:number= redGames.indexOf(gameId)if(0<= indexInRed) redGames.splice(indexInRed,1)returntrue}else{const blackInfo: PlayerInfo = db.players[game.black]??{
gameIds:[],}const redInfo: PlayerInfo = db.players[game.red]??{
gameIds:[],}console.log(`Patch game: new, ${gameId}, black: ${game.black}, red: ${game.red}`)if(blackInfo.gameIds.indexOf(gameId)<0) blackInfo.gameIds.push(gameId)if(redInfo.gameIds.indexOf(gameId)<0) redInfo.gameIds.push(gameId)
db.players[game.black]= blackInfo
db.players[game.red]= redInfo
db.games[gameId]={
redAddress: game.red,
blackAddress: game.black,
deleted:false,}returntrue}} src server indexer.ts View source
There are some issues to be aware of:
JavaScript is not thread-safe, so you could cause two opposite actions: one coming from the polling and the other from a patch submission, or even from two concurrent patch submissions. To reduce this risk the database is not saved to disk in this function, but instead relies on the polling to save it at the next run.
Assuming that there is no such game when you cannot find it can result in deleting data that is simply taking time to appear on your blockchain node.
Next, you need to call patchGame from the app.patch callback:
Copy
app.patch("/games/:gameId",async(req: Request, res: Response)=>{const found =awaitpatchGame(req.params.gameId)if(!found) res.status(404)else{
res.json({
result:"Thank you",})}}) src server indexer.ts View source