Chapter 07: Nominating candidates
In the previous chapters you laid the groundwork for the election smart contract that will become the back-end of the election website. The election Pact module will store a list of candidates and the number of votes they have received, as well as a list of the accounts that have already voted, to make sure that every account can vote only once. In this chapter you will set up the candidates database table and add functions to the module for adding candidates to the table and listing all candidates that are stored in the table.
Recommended reading
Get the code
If you are following along with the tutorial you can continue working on your current branch. In case you started the tutorial with this chapter, clone the tutorial project and change the current directory of your terminal to the project folder.
git clone git@github.com:kadena-community/voting-dapp.git election-dappcd election-dapp
git clone git@github.com:kadena-community/voting-dapp.git election-dappcd election-dapp
Switch branches to get the starter code for this chapter.
git checkout 07-nominate-candidates
git checkout 07-nominate-candidates
If you want to skip ahead and see the final solution for this chapter, you can check out the branch containing the starter code for the next chapter.
git checkout 08-voting
git checkout 08-voting
Database
To prepare the Pact module's database you need to define a schema for the table and define
the table with the schema inside the election
Pact module. The actual creation of the table
happens outside the Pact module, just like selecting the namespace.
Define candidates schema and table
Add the following lines to ./pact/election.pact
, inside the election
module definition.
The defschema
command defines a candidate-schema
with two columns: name
of type string
and votes
of type integer.
(defschema candidates-schema "Candidates table schema" name:string votes:integer) (deftable candidates:{candidates-schema})
(defschema candidates-schema "Candidates table schema" name:string votes:integer) (deftable candidates:{candidates-schema})
Create candidates table
Add the following lines at the end of ./pact/election.pact
, after the election
module
definition. With read-msg
, the field init-candidates
is read from the transaction data
object. If you set this field to true
in the data of your module deployment transaction, the
statement between the first square brackets will be executed. This will create the table
candidates
based on its definitions inside the module.
(if (read-msg "init-candidates") [(create-table candidates)] [])
(if (read-msg "init-candidates") [(create-table candidates)] [])
Before trying to create the table on Devnet, verify that your changes work as expected by
running some tests in the Pact REPL. Create a file ./pact/election.repl
and set up your
test environment with transactions you can borrow from previous chapters.
(env-data { 'admin-keyset: { 'keys : [ "128c32eb3b4d99be6619aa421bc3df9ebc91bde7a4acf5e8eb9c27f553fa84f3" ] , 'pred : 'keys-all } , 'upgrade: false , 'init-candidates: true }) (env-sigs [{ 'key : "128c32eb3b4d99be6619aa421bc3df9ebc91bde7a4acf5e8eb9c27f553fa84f3" , 'caps : [] }]) (begin-tx "Define principal namespace") (define-namespace 'n_fd020525c953aa002f20fb81a920982b175cdf1a (read-keyset 'admin-keyset ) (read-keyset 'admin-keyset ))(commit-tx) (begin-tx "Define admin-keyset") (namespace 'n_fd020525c953aa002f20fb81a920982b175cdf1a) (define-keyset "n_fd020525c953aa002f20fb81a920982b175cdf1a.admin-keyset" (read-keyset 'admin-keyset ))(commit-tx) (begin-tx "Load election module") (load "election.pact")(commit-tx)
(env-data { 'admin-keyset: { 'keys : [ "128c32eb3b4d99be6619aa421bc3df9ebc91bde7a4acf5e8eb9c27f553fa84f3" ] , 'pred : 'keys-all } , 'upgrade: false , 'init-candidates: true }) (env-sigs [{ 'key : "128c32eb3b4d99be6619aa421bc3df9ebc91bde7a4acf5e8eb9c27f553fa84f3" , 'caps : [] }]) (begin-tx "Define principal namespace") (define-namespace 'n_fd020525c953aa002f20fb81a920982b175cdf1a (read-keyset 'admin-keyset ) (read-keyset 'admin-keyset ))(commit-tx) (begin-tx "Define admin-keyset") (namespace 'n_fd020525c953aa002f20fb81a920982b175cdf1a) (define-keyset "n_fd020525c953aa002f20fb81a920982b175cdf1a.admin-keyset" (read-keyset 'admin-keyset ))(commit-tx) (begin-tx "Load election module") (load "election.pact")(commit-tx)
You will recognize env-data
and env-sigs
. Be sure to replace the keys with the public
key of your own admin account. Notice that 'init-candidates: true
is included in the data.
This ensures that the (create-table candidates)
command is executed when you load the
election
module into the Pact REPL. The first two transactions define your principal
namespace and an admin-keyset
therein. Replace the principal namespace with the namespace
used in your election.pact
file. The last transaction, loading election.pact
, would
fail without this namespace and keyset being defined, because inside election.pact
you
defined the election
module in that principal namespace and it is governed by the
admin-keyset
in that same namespace. Try removing either or both of the first two
transactions and run election.repl
to see what would happen. Then, restore the file and run it
again. You will see that it loads successfully. In the output you should also see a
message containing TableCreated
, proving that the table was indeed created.
List candidates
Although the candidates table seems to have been created successfully, it is worth testing
that the table works as expected before upgrading the election
module on Devnet.
Start by writing a test for the current implementation of the election.list-candidates
function in ./pact/election.repl
and run the file.
(begin-tx "List candidates") (use n_fd020525c953aa002f20fb81a920982b175cdf1a.election) (expect "There should be no candidates in the candidates table" [1, 2, 3] (list-candidates) )(commit-tx)
(begin-tx "List candidates") (use n_fd020525c953aa002f20fb81a920982b175cdf1a.election) (expect "There should be no candidates in the candidates table" [1, 2, 3] (list-candidates) )(commit-tx)
Now, change the expected output to []
and run the file again. You should now have a failing
test that can be fixed by updating the return value of the list-candidates
function in
./pact/election.pact
with the following statement that selects all rows of an existing
table, including the key and the column values of each row. Run ./pact/election.repl
again
and observe that the test passes.
(fold-db candidates (lambda (key columnData) true) (lambda (key columnData) (+ { "key": key } columnData)))
(fold-db candidates (lambda (key columnData) true) (lambda (key columnData) (+ { "key": key } columnData)))
The fold-db
is like a SELECT * FROM table
statement in SQL. The major difference is
that it fetches the value of the identifier column key
separately from the other column
values. The first argument of fold-db
is the table name. The second
argument is a predicate function that determines which rows should be
selected. To fetch all rows from a table you can simply return true
here. The third argument
is an accumulator function that allows you
to map the data of each row to a different format. In this example, you will append the "key"
field with the value of each row's key
to the column data object containing the values for
each field defined in the candidates-schema. This results in a return value of the
fold-db
function with the following structure.
[ { "key": "1", "name": "Candidate A", "votes": 0 }, { "key": "2", "name": "Candidate B", "votes": 0 }]
[ { "key": "1", "name": "Candidate A", "votes": 0 }, { "key": "2", "name": "Candidate B", "votes": 0 }]
It is not recommended to send transactions that include a call to fold-db
to the blockchain. Instead, you can just make a local request to select
all rows from a table to save gas, similar to the preview functionality in Chainweaver
that you used in the previous chapter. Later in this chapter you will learn how to make such
a request using the Kadena JavaScript client.
Changing the first argument of fold-db
to something else than candidates
would make the
test fail with an error containing Cannot resolve
, which proves that the candidates
table
exists and is readable.
Add candidate
Without any candidates the election website would not be very interesting, as there would
not be anyone to vote on. Add the following test that will fail because the
add-candidate
function cannot be resolved. You will fix the test later by
implementing this function in the election
module.
(begin-tx "Add candidates") (use n_fd020525c953aa002f20fb81a920982b175cdf1a.election) (expect "Add Candidate A" "Write succeeded" (add-candidate { "key": "1", "name": "Candidate A" }) ) (expect "Add Candidate B" "Write succeeded" (add-candidate { "key": "2", "name": "Candidate B" }) ) (expect "Add Candidate C" "Write succeeded" (add-candidate { "key": "3", "name": "Candidate C" }) )(commit-tx)
(begin-tx "Add candidates") (use n_fd020525c953aa002f20fb81a920982b175cdf1a.election) (expect "Add Candidate A" "Write succeeded" (add-candidate { "key": "1", "name": "Candidate A" }) ) (expect "Add Candidate B" "Write succeeded" (add-candidate { "key": "2", "name": "Candidate B" }) ) (expect "Add Candidate C" "Write succeeded" (add-candidate { "key": "3", "name": "Candidate C" }) )(commit-tx)
The add-candidate
function will accept a candidate object as an argument, defined
in json format. Notice that this object has the fields key
and name
, while the
candidate-schema
you defined for the candidates
table has two columns name
and
votes
. That it is because the votes
column will always get an initial value
of 0
when a new candidate is added, so it is not necessary to send the amount of
votes along with the transaction. The value of key
will be used as a unique
index for the table row that is added. It cannot be automatically generated, so you have
to pass a value yourself.
You should now have a failing test that you can fix by implementing the add-candidate
function in ./pact/election.pact
. The function will receive one argument candidates
,
which is a json object like the ones specified in your test. The function body consists
of a single call to the built-in insert
function that takes three arguments. The first
argument is a reference to the table you want to use, the candidates
table in this
case. The second argument is the value for the key of the row to be inserted. In the
example below, the value of the key
field is extracted from the candidate
object
that is passed into the add-candidate
function as an argument. The third argument
of the insert
function is a key-value object representing the row to be inserted
into the table. The keys correspond to the column names. So, in this example, the votes
column of the new value will always get a value 0
and the name
column will get a value
of either "Candidate A"
, "Candidate B"
, or "Candidate C"
, as per your test cases.
(defun add-candidate (candidate) (insert candidates (at 'key candidate) { "name": (at 'name candidate), "votes": 0 } ))
(defun add-candidate (candidate) (insert candidates (at 'key candidate) { "name": (at 'name candidate), "votes": 0 } ))
Add the function above to the ./pact/election.pact
file, run the ./pact/election.repl
file again and verify that all tests are now passing. Remember that the key of each row
in a table must be unique. Add the following code to ./pact/election.repl
to test that
adding another candidate with key 1
will fail with a Database exception
and run the
file again.
(begin-tx "Add candidate with existing key") (use n_fd020525c953aa002f20fb81a920982b175cdf1a.election) (expect-failure "Adding a candidate with an existing key should fail" "Database exception: Insert: row found for key 1" (add-candidate { "key": "1", "name": "Candidate D" }) )(commit-tx)
(begin-tx "Add candidate with existing key") (use n_fd020525c953aa002f20fb81a920982b175cdf1a.election) (expect-failure "Adding a candidate with an existing key should fail" "Database exception: Insert: row found for key 1" (add-candidate { "key": "1", "name": "Candidate D" }) )(commit-tx)
At this point, there should still be only three candidates in the table. You can verify
that by adding the following assertion to ./pact/election.repl
and running the file
once more.
(begin-tx "List candidates") (use n_fd020525c953aa002f20fb81a920982b175cdf1a.election) (expect "There should be three candidates" 3 (length (list-candidates)) )(commit-tx)
(begin-tx "List candidates") (use n_fd020525c953aa002f20fb81a920982b175cdf1a.election) (expect "There should be three candidates" 3 (length (list-candidates)) )(commit-tx)
This demonstration of the unique key constraint for database tables also provides more
confidence that the list-candidates
function works as expected. Before upgrading the
election
module on Devnet, there is just one more thing that must be added.
Guard adding candidates with a capability
Right now, the add-candidate
function is publically accessible, meaning that anyone
with a Kadena account would be able nominate a candidate. What kind of democracy would
that be, where everyone has the right to vote and to be nominated for election? No, no, no,
in the real world, you have to know the right people to get elected, like the holder
of the admin-keyset
. To that end, the add-candidate
function can be guarded by the
GOVERNANCE
capability that enforces the admin-keyset
. At the end of
./pact/election.repl
, define another keyset and add a failing test in which you
expect adding a fourth candidate to fail. Run the file when you are done.
(env-data { 'admin-keyset : { 'keys : [ 'other-key ] , 'pred : 'keys-all } }) (env-sigs [{ 'key : 'other-key , 'caps : [] }]) (begin-tx "Add candidate without permission") (use n_fd020525c953aa002f20fb81a920982b175cdf1a.election) (expect-failure "Adding a candidate with the wrong keyset should fail" "Keyset failure (keys-all)" (add-candidate { "key": "4", "name": "Candidate D" }) )(commit-tx)
(env-data { 'admin-keyset : { 'keys : [ 'other-key ] , 'pred : 'keys-all } }) (env-sigs [{ 'key : 'other-key , 'caps : [] }]) (begin-tx "Add candidate without permission") (use n_fd020525c953aa002f20fb81a920982b175cdf1a.election) (expect-failure "Adding a candidate with the wrong keyset should fail" "Keyset failure (keys-all)" (add-candidate { "key": "4", "name": "Candidate D" }) )(commit-tx)
Fix the test by implementing the capability guard in ./pact/election.pact
as follows and run
./pact/election.repl
again.
(defun add-candidate (candidate) (with-capability (GOVERNANCE) (insert candidates (at 'key candidate) { "name": (at 'name candidate), "votes": 0 } ) ))
(defun add-candidate (candidate) (with-capability (GOVERNANCE) (insert candidates (at 'key candidate) { "name": (at 'name candidate), "votes": 0 } ) ))
The with-capability
function will try to bring the GOVERNANCE
in scope of the code block
that it wraps. If it fails to do so, because of a keyset failure in this case, the wrapped
code block will not be executed. Thus, the add-candidate
function is now guarded by the
GOVERNANCE
capability.
Upgrade election module on Devnet
Open up a terminal and change the directory to the ./snippets
folder in the root of
your project. Execute the ./deploy-module.ts
snippet by running the following command.
Replace k:account
with your admin account. Also, make sure that Devnet is running and
Chainweaver is open so you can sign the transaction. In addition to the account name, you
need to pass upgrade
and init-candidates
as arguments. This will add
{"init-candidates": true, "upgrade": true}
to the transaction, allowing you to upgrade the
module and execute (create-table candidates)
at the bottom of your ./pact/election.pact
.
npm run deploy-module:devnet -- k:account upgrade init-candidates
npm run deploy-module:devnet -- k:account upgrade init-candidates
If all is well, the last line of the output will be as follows.
{ status: 'success', data: [ 'TableCreated' ] }
{ status: 'success', data: [ 'TableCreated' ] }
Look up your election
module in the Module Explorer of Chainweaver. Click the refresh button
at the top right of the table and view the module. You should see the add-candidate
being
added to the list of functions in the right pane. Click the Open
button on the top right
to load the Pact code into the editor in the left pane and verify that the election
module
on Devnet is in sync with the version on your local computer.
Connect the front-end to Devnet
Now that you have a working election smart contract running on Devnet, the time has finally
come to connect the front-end of the election website to the blockchain. The front-end
uses repositories to exchange data with the back-end. The interfaces of these repositories
are defined in frontend/src/types.ts
. By default, the front-end is configured
to use the in-memory implementations of the repositories. After making some changes to the
Devnet implementation of the interface ICandidateRepository
in
frontend/src/repositories/candidate/DevnetCandidateRepository.ts
, you can configure the
front-end to use the devnet
back-end instead of the in-memory
back-end. Then, you
should be able to add candidates to the candidates table of your election module on Devnet,
and list nominated candidates, via the forms on your election website.
List candidates
Open frontend/src/repositories/candidate/DevnetCandidateRepository.ts
in your editor. For
now, you only need to focus on the listCandidates
and addCandidates
. The implementation
of these repository method is slightly more complex than the in-memory implementation in
frontend/src/repositories/candidate/InMemoryCandidateRepository.ts
, but the pattern
should look familiar to the snippets you have used in the previous chapters. Start easy by
replacing the value NAMESPACE
constant with your own principal namespace.
const NAMESPACE = 'n_fd020525c953aa002f20fb81a920982b175cdf1a';
const NAMESPACE = 'n_fd020525c953aa002f20fb81a920982b175cdf1a';
Have a look at the first three lines of the listCandidates
function. There @ts-ignore
comment suppresses a type error on the following line.
const transaction = Pact.builder // @ts-ignore .execution(Pact.modules[`${NAMESPACE}.election`]['list-candidates']())
const transaction = Pact.builder // @ts-ignore .execution(Pact.modules[`${NAMESPACE}.election`]['list-candidates']())
Remove the comment and inspect the problem displayed in your editor or thrown when you
run the project. The problem is that the name of your module cannot be found in Pact.modules
.
To fix the error, you need to generate types for your election Pact module, that can be
picked up by @kadena/client
.
npm run pactjs:generate:contract:election
npm run pactjs:generate:contract:election
The error should have disappeared from your editor. If you are using Visual Studio Code, you
may have to reload the window first. The front-end should now be fine to build and run.
Before the transaction is created, only the chain id and network id are configured, and
a remarkably high gas limit is set. Apparently, sending a full table read transaction to
the blockchain is quite expensive. Fortunately, you can preview the result, like you
did in Chainweaver earlier, without actually sending a transaction to the blockchain. The
dirtyRead
method of the client conveniently handles this preview request. The rest
of the listCandidates
function deals with processing the response from Devnet. If the
transaction was successful, a list of candidates is returned, otherwise an empty list.
Open up a terminal with the current directory set to ./frontend
relative to the root
of your project. Run the front-end application configured with the devnet
back-end by
executing the following commands. Visit http://localhost:5173
in your browser and
verify that the website loads without errors.
npm installnpm run start-devnet
npm installnpm run start-devnet
Add candidate
While the front-end configured with the in-memory back-end displayed a list of five
candidates right away, no candidates will be displayed when using the devnet back-end.
You will first need to add candidates. Before adding a candidate, open
frontend/src/repositories/candidate/DevnetCandidateRepository.ts
in your editor and
make sure you understand the addCandidate
implementation. The function receives a
candidate object and the account of the transaction sender. These will be provided by
you via the form on the website. Remove the @ts-ignore
comment and observe that the
insert-candidate
function of your election
module will be called with the candidate
object when the transaction is executed. Recall that the add-candidate
function is
guarded by the GOVERNANCE
capability that enforces the admin-keyset
. That is why
the following data and signer need to be added to the transaction.
.addData('admin-keyset', { keys: [accountKey(sender)], pred: 'keys-all',}).addSigner(accountKey(sender))
.addData('admin-keyset', { keys: [accountKey(sender)], pred: 'keys-all',}).addSigner(accountKey(sender))
Notice that these lines correspond to the following code in your ./pact/election.repl
file.
(env-data { 'admin-keyset: { 'keys : [ "128c32eb3b4d99be6619aa421bc3df9ebc91bde7a4acf5e8eb9c27f553fa84f3" ] , 'pred : 'keys-all } }) (env-sigs [{ 'key : "128c32eb3b4d99be6619aa421bc3df9ebc91bde7a4acf5e8eb9c27f553fa84f3" , 'caps : [] }])
(env-data { 'admin-keyset: { 'keys : [ "128c32eb3b4d99be6619aa421bc3df9ebc91bde7a4acf5e8eb9c27f553fa84f3" ] , 'pred : 'keys-all } }) (env-sigs [{ 'key : "128c32eb3b4d99be6619aa421bc3df9ebc91bde7a4acf5e8eb9c27f553fa84f3" , 'caps : [] }])
In contrast to when you listed candidates, the transaction for adding candidates is actually
sent to the blockchain, so gas must be paid for processing the transaction. The value of the
senderAccount
field of the metadata specifies the account that will pay for gas. This is
important to remember, because in the next chapters you will specify the account of a
gas station to pay for the gas of a transaction that is signed by the account of a voter.
The transaction to add a candidate will be signed and paid by the same account, though.
.addSigner(accountKey(sender)).setMeta({ chainId: CHAIN_ID, senderAccount: sender,})
.addSigner(accountKey(sender)).setMeta({ chainId: CHAIN_ID, senderAccount: sender,})
Another difference between the listCandidates
and addCandidate
implementation is the use
of a preflight request in the addCandidate
function. This allows you to dry run the
transaction without actually sending the transaction and paying for gas. The preflight
response contains information about the expected success of the transaction and the
amount of gas it will cost. If the transaction would fail or the gas fee is higher than you
would like, you can choose not to send the transaction. This helps to prevent unnecessary loss
of KDA paid for gas.
const preflightResponse = await client.preflight(signedTx); if (preflightResponse.result.status === 'failure') { throw preflightResponse.result.error;}
const preflightResponse = await client.preflight(signedTx); if (preflightResponse.result.status === 'failure') { throw preflightResponse.result.error;}
The remainder of the addCandidate
function deals with sending the transaction and processing
the response. An error will be thrown if the transaction fails.
Make sure that Chainweaver is open so you can sign the request. Then, enter your admin account
name on the election website. Click the Add candidate
button that appears and add a candidate
in the following format.
{ "key": "1", "name": "Your name" }
{ "key": "1", "name": "Your name" }
After signing the request, a loading indicator will be displayed on the website while the transaction is in progress. As soon as the transaction completes successfully, the loading indicator will disappear and the candidate you nominated will be added to the list. Great job!
Next steps
In this chapter, you have upgraded the smart contract for your election website. You added a candidates table and functions for listing and adding candidates to the table. Furthermore, you connected the front-end of the election website to you local Devnet. The only thing left to finish the election website is to make it possible to cast a vote on one of the candidates. In the next chapter, you will upgrade the election module with functionality that enables people to cast a vote on a candidate with their Kadena account.