# Token - Let Players Set a Wager
Make sure you have everything you need before proceeding:
- You understand the concepts of modules, keepers, and Protobuf.
- Go is installed.
- You have the checkers blockchain codebase up to game expiry handling. If not, follow the previous steps or check out the relevant version (opens new window).
In this section, you will:
- Add wagers.
- Work with the Bank module.
- Handle money.
- Do integration tests.
With the introduction of game expiry in the previous section and other features, you have now addressed the cases when two players start a game and finish it, or let it expire.
In this section, you will add an extra layer to a game, with wagers or stakes. Your application already includes all the necessary modules. This section relies on the bank
module in particular.
Players choose to wager money or not, and the winner gets both wagers. The forfeiter loses their wager. To reduce complexity, start by letting players wager in the staking token of your application.
Now that no games can be left stranded, it is possible for players to safely wager on their games. How could this be implemented?
# Some initial thoughts
When thinking about implementing a wager on games, ask:
- What form will a wager take?
- Who decides on the amount of wagers?
- Where is a wager recorded?
- Is there any desirable atomicity of actions?
- At what junctures do you need to handle payments, refunds, and wins?
- Are there errors to report back?
- What event should you emit?
# Code needs
When it comes to your code:
- What Ignite CLI commands, if any, will assist you?
- How do you adjust what Ignite CLI created for you?
- Where do you make your changes?
- How would you unit-test these new elements?
- How would you use Ignite CLI to locally run a one-node blockchain and interact with it via the CLI to see what you get?
# New information
Add this wager value to the StoredGame
's Protobuf definition:
You can let players choose the wager they want by adding a dedicated field in the message to create a game, in proto/checkers/tx.proto
:
Have Ignite CLI and Protobuf recompile these two files:
Now add a helper function to StoredGame
using the Cosmos SDK Coin
in full_game.go
:
This encapsulates information about the wager (where sdk.DefaultBondDenom
is most likely "stake"
).
# Saving the wager
Time to make sure that the new field is saved in the storage and it is part of the creation event.
Define a new event key as a constant:
Set the actual value in the new
StoredGame
as it is instantiated in the create game handler:And in the event:
Modify the constructor among the interface definition of
MsgCreateGame
inx/checkers/types/message_create_game.go
to avoid surprises:
# Declaring expectations
On its own the Wager
field does not make players pay the wager or receive rewards. You need to add handling actions which ask the bank
module to perform the required token transfers. For that, your keeper needs to ask for a bank
instance during setup.
The only way to have access to a capability with the object-capability model of the Cosmos SDK is to be given the reference to an instance which already has this capability.
Payment handling is implemented by having your keeper hold wagers in escrow while the game is being played. The bank
module has functions to transfer tokens from any account to your module and vice-versa.
Alternatively, your keeper could burn tokens when playing and mint them again when paying out. However, this makes your blockchain's total supply falsely fluctuate. Additionally, this burning and minting may prove questionable when you later introduce IBC tokens.
Declare an interface that narrowly declares the functions from other modules that you expect for your module. The conventional file for these declarations is x/checkers/types/expected_keepers.go
.
The bank
module has many capabilities, but all you need here are two functions. Therefore, you redeclare the functions like so:
These two functions must exactly match the functions declared in the bank
's keeper.go file (opens new window). Copy the declarations directly from the bank
's file. In Go, any object with these two functions is a BankKeeper
.
# Obtaining the capability
With your requirements declared, it is time to make sure your keeper receives a reference to a bank keeper. First add a BankKeeper
to your keeper in x/checkers/keeper/keeper.go
:
This BankKeeper
is your newly declared narrow interface. Do not forget to adjust the constructor accordingly:
Next, update where the constructor is called and pass a proper instance of BankKeeper
. This happens in app/app.go
:
This app.BankKeeper
is a full bank
keeper that also conforms to your BankKeeper
interface.
Finally, inform the app that your checkers module is going to hold balances in escrow by adding it to the list of permitted modules:
It is only keeping funds in escrow and not minting or burning, hence the nil
.
One last step. Before your module can keep money in escrow, it needs to be whitelisted by the bank module. You do this in the maccperms
:
If you compare it to the other maccperms
lines, the new line does not mention any authtypes.Minter
or authtypes.Burner
. Indeed nil
is what you need to keep in escrow. For your information, the bank creates an address for your module's escrow account. When you have the full app
, you can access it with:
# Preparing expected errors
There are several new error situations that you can enumerate with new variables:
# Money handling steps
With the bank
now in your keeper, it is time to have your keeper handle the money. Keep this concern in its file, as the functions are reused on a play, reject, and forfeit.
Create the new file x/checkers/keeper/wager_handler.go
and add three functions to collect a wager, refund a wager, and pay winnings:
The Must
prefix in the function means that the transaction either takes place or a panic is issued. If a player cannot pay the wager, it is a user-side error and the user must be informed of a failed transaction. If the module cannot pay, it means the escrow account has failed. This error is much more serious: an invariant has been violated and the whole application must be terminated.
Now set up collecting a wager, paying winnings, and refunding a wager:
Collecting wagers happens on a player's first move. Therefore, differentiate between players:
Get the address for the black player:
Try to transfer into the escrow:
Then do the same for the red player.
Paying winnings takes place when the game has a declared winner. First get the winner. "No winner" is not an acceptable situation in this
MustPayWinnings
. The caller of the function must ensure there is a winner:Then get the winnings to pay:
You double the wager only if the red player has also played and therefore both players have paid their wagers. Then pay the winner:
Finally, refunding wagers takes place when the game has partially started, i.e. only one party has paid, or when the game ends in a draw. In this narrow case of
MustRefundWager
:Refund the black player when there has been a single move:
If the module cannot pay, then there is a panic as the escrow has failed.
You will notice that no special case is made when the wager is zero. This is a design choice here, and which way you choose to go is up to you. Not contacting the bank unnecessarily is cheaper in gas. On the other hand, why not outsource the zero check to the bank?
# Insert wager handling
With the desired steps defined in the wager handling functions, it is time to invoke them at the right places in the message handlers.
When a player plays for the first time:
When a player wins as a result of a move:
When a player rejects a game:
When a game expires and there is a forfeit, make sure to only refund or pay full winnings when applicable. The logic needs to be adjusted:
# Integration tests
If you try running your existing tests you will see a lot of null pointer exceptions. That's because currently the tests set up your checkers keeper without a bank keeper (opens new window). Cosmos SDK does not have mocks (opens new window), so instead of passing a mocked bank when setting up your test keeper you need to build a proper bank keeper too. Fortunately, you do not have to do this from scratch: taking inspiration from tests on the bank module (opens new window), prepare your code and tests in order to accommodate and create a full app which will contain a bank keeper.
Your existing tests, although never pure unit tests, will become true integration tests.
Previously, each test function took a t *testing.T
(opens new window) object. Now, each test function will be a method on a test suite that inherits from testify's suite (opens new window). This has the advantage that your test suite can have as many fields as is necessary or useful. The objects that you have used and would welcome in the suite are:
You can spread the suite's methods to different files, so as to keep consistent naming for your test files.
When testing, go test
will find the suite because you add a regular test (opens new window) that initializes the suite and runs it. The test suite is then automatically initialized with its SetupTest
(opens new window) function via its parent suite
class. After that, all the methods of the test suite are run.
# Accommodate your code
To get the compilation error out of the way, for basic tests that do not require integration you can add an empty bank keeper on
func setupKeeper(t testing.TB)
:Keep this
setupKeeper
function because tests created by Ignite CLI expect it.Ignite CLI created a default constructor for your App with a
cosmoscmd.App
(opens new window) return type, but this is not convenient as you need access to theapp.App
type for initialization in the upcoming tests. Instead of risking breaking other dependencies, add a new constructor with yourApp
(opens new window) as the return type.Add other elements taken from Cosmos SDK tests, like
encoding.go
(opens new window),proto.go
(opens new window), andtest_helpers.go
(opens new window), in which you must also initialize your checkers genesis:Define your test suite in a new
keeper_integration_test.go
file:Direct
go test
to it:Create the
suite.SetupTest
function, taking inspiration from the bank tests (opens new window):This
SetupTest
function (opens new window) is like abeforeEach
as it is named in other test libraries. With it, you always get a newapp
with each test, without interference between them. Do not omit it (opens new window) unless you have specific reasons to do so.Also note that it collects your
checkersModuleAddress
for later use in tests that check events and balances:
# Helpers for money checking
Your new tests will include checks on wagers being paid, lost, and won, so your tests need to initialize some bank balances for your players. This is made easier with a few helpers, including a helper to confirm a bank balance.
Make a bank genesis
Balance
(opens new window) type from primitives:Declare default balances that will be useful for you:
Make your preferred bank genesis state:
Add a simple function to prepare your suite with your desired balances:
Add a function to check balances from primitives:
Update any functions you used to set up your keeper with one game, for instance:
With the preparation done, what does a test method look like?
# Anatomy of an integration suite test
Now you must refactor the existing tests that test your keeper. What does a refactored test look like?
The method declaration:
It is declared as a member of your test suite, and is prefixed with
Test
(opens new window).The setup can be done as you like, but since you created a helper you can use it:
The action does not change from before, other than that you get the
keeper
ormsgServer
from the suite's fields:The verification is done with
suite.Require().X
, but otherwise looks similar to the shorterrequire.X
:In fact, it is exactly the same
require
(opens new window) object. A basic search and replace should work.If you need access to the checkers keeper, it can also be found in the suite:
# What happened to the events?
As you refactor your existing tests, you may notice that the events become messed up. That is because the bank emits events too, and in particular it emits:
An event with the
message
type, like yours, with only the sender:An event with the
transfer
type:
This means that in your suite.ctx.EventManager().ABCIEvents()
there are extra events (opens new window) to account for, and in each case there are extra attributes to discard. Recommended steps:
Make explicit the count of expected attributes for each event type:
Make calculations on the expected count of attributes to discard, depending on the actions previously taken:
You can now refactor your tests, which is a substantial task.
# Extra tests
After refactoring, and finding no failing tests, it is time to add extra checks of money handling. For instance, before an action that you expect to transfer money (or not), you can verify the initial position:
After the action you can test the new balances, for instance:
How you subdivide your tests and where you insert these balance checks is up to you. You can find examples here for:
- Creating a game (opens new window).
- Playing a game (opens new window), including up to a resolution (opens new window).
- Failing to play a game because of a failure to pay the wager (opens new window).
- Rejecting a game (opens new window), including when there have been moves played (opens new window).
- Forfeiting a game (opens new window), including when there have been moves played (opens new window).
# Debug your suite
You learned in a previous section how to launch your test in debug mode. It is still possible to do so when using a suite. The difference is that you launch it by right-clicking on the arrow left of the suite's runner func TestCheckersKeeperTestSuite
:
Note that you can only launch debug for all of the suite's test methods and not just a single one (as is possible with a simple test). A solution to this is to create more granular suites, for example using one or more test suites per file.
# Interact via the CLI
Keep the game expiry at 5 minutes to be able to test a forfeit, as done in the previous section. Now, you need to check balances after relevant steps to test that wagers are being withheld and paid.
How much do Alice and Bob have to start with?
Create a game on which the wager will be refunded because the player playing red
did not join:
Which mentions the wager:
Confirm that the balances of both Alice and Bob are unchanged - as they have not played yet.
Note: In this example Alice paid no gas fees, other than the transaction costs, to create a game. This is fixed in the next section.
Have Bob play:
Confirm that Bob has paid his wager:
This prints:
Wait 5 minutes for the game to expire and check again:
This prints:
Now create a game in which both players only play once each, i.e. where the player playing black
forfeits:
Confirm that both Alice and Bob paid their wagers. Wait 5 minutes for the game to expire and check again:
This is correct: Alice was the winner by forfeit.
Similarly, you can test that Bob gets his wager back when Alice creates a game, Bob plays, and then Alice rejects it.
It would be difficult to test by CLI when there is a winner after a full game. That would be better tested with a GUI.
# Next up
You can skip ahead and see how to integrate foreign tokens via the use of IBC, or take a look at the next section to prevent spam and reward validators proportional to their effort in your checkers blockchain.