This is Part 2 of 3 of the Series “Developing a Full-Stack Project on the Stacks Blockchain with Clarity Smart Contracts and Stacks.js”. If you haven’t already, start at the beginning with Part 1: Intro and Project Setup.
Backend
This contract will simply post “gm” from a user to the chain for a small fee. This is done by mapping the string “gm” to the user’s unique STX address. The functionality will be basic but enough to demonstrate concepts, testing, and frontend interaction.
In gm.clar
goes the code:
;; gm
;; smart contract that posts a GM to the chain for 1 STX
;; constants
(define-constant CONTRACT_OWNER tx-sender)
(define-constant PRICE u1000000)
(define-constant ERR_STX_TRANSFER (err u100))
;; data maps and vars
(define-data-var total-gms uint u0)
(define-map UserPost principal (string-ascii 2))
;; public functions
(define-read-only (get-total-gms)
(var-get total-gms)
)
(define-read-only (get-gm (user principal))
(map-get? UserPost user)
)
(define-public (say-gm)
(begin
(unwrap! (stx-transfer? PRICE tx-sender CONTRACT_OWNER) ERR_STX_TRANSFER)
(map-set UserPost tx-sender "gm")
(var-set total-gms (+ (var-get total-gms) u1))
(ok "Success")
)
)
We have data space at the top of the file and all of the functions listed after.
Data:
define-constant
: There are 3 constants defined. A constant is simply an immutable piece of data, meaning it cannot be changed once defined. In this case, I am using constants to define the contract deployer (I will go into this concept more in a bit), the price of writing the message denoted in microstacks (1,000,000 microstacks is equal to 1 STX), and an error response.define-data-var
: A variable is a piece of data that can be changed by means of future calls. They are, however, only modifiable by the contract in which they are defined. I am defining a variable to track the total number of writes.define-map
: A map is a data structure that allows you to map keys to values. I will be mapping a principal (STX wallet address) to the “gm” string.
Functions:
There are 3 functions; 2 read-only and 1 public.
A read-only
function can only perform read operations. It cannot write or make changes to the chain. As you can see, our read-only
functions are simply grabbing the value of our variable
and map
.
Now, I’ll go line-by-line through the say-gm
public function where the work is being done.
(define-public (say-gm)
(begin
(unwrap! (stx-transfer? PRICE tx-sender CONTRACT_OWNER) ERR_STX_TRANSFER)
(map-set UserPost tx-sender "gm")
(var-set total-gms (+ (var-get total-gms) u1))
(ok "Success")
)
)
A Clarity custom function takes the following form:
(define-public function-signature function-body)
The function definition can be any of the three Clarity function types: public
, private
, or read-only
The function signature is made up of the function name and any input parameters taken by the function.
The function body contains the function logic, is limited to one expression, and in the case of a public-function
must return a response type of ok
or err
.
The function signature in this example is:
(define-public (say-gm))
The function body is:
(begin
(unwrap! (stx-transfer? PRICE tx-sender CONTRACT_OWNER) ERR_STX_TRANSFER)
(map-set UserPost tx-sender "gm")
(var-set total-gms (+ (var-get total-gms) u1))
(ok "Success")
)
What exactly is happening here?
begin
is one of the Clarity built-in functions. Recall that I mentioned the function body is limited to one expression, we usebegin
to evaluate multiple expressions in a single function.
Next we have:
(unwrap! (stx-transfer? PRICE tx-sender CONTRACT_OWNER) ERR_STX_TRANSFER)
There are a couple of things happening in this line so let’s break it into two:
(stx-transfer? PRICE tx-sender CONTRACT_OWNER)
is using another built-in function stx-transfer?
which increases the STX balance of the recipient by debiting the sender.
PRICE
is a constant that was defined at the top the file.tx-sender
is a Clarity keyword representing the address that called the function.CONTRACT_OWNER
is also a constant that was defined at the top of the file.
… But wait a minute!
(define-constant CONTRACT_OWNER tx-sender)
CONTRACT_OWNER
is tx-sender
.
tx-sender
returns the address of the transaction sender, but it’s context changes based on where and how it is used.
In the case of CONTRACT_OWNER
, tx-sender
will take the context of the standard principal that deployed the contract a.k.a the contract deployer.
Whereas within the say-gm
function, tx-sender
has the context of the standard principal that is calling into the function.
You can also manually change the context by using the as-contract
built-in function to set tx-sender
to the contract principal.
I will demonstrate this visually during manual testing.
For now, let’s get back to the expression.
The expression will return (ok true)
if the transfer is successful, else it will return an (err)
response. This is where unwrap!
comes in. As the name suggests, it attempts to unwrap the result of the argument passed to it. Unwrapping is extracting the inner value and returning it. If the response is (ok ...)
it will return the inner value otherwise it returns a throw value.
(unwrap! (stx-transfer? PRICE tx-sender CONTRACT_OWNER) ERR_STX_TRANSFER)
So the above expression is unwrapping the result of stx-transfer?
Now we have:
(map-set UserPost tx-sender "gm")
map-set
sets the value of the input key to the input value. As part of this function call, I am setting the corresponding value of “gm” to the key of the standard principal calling the function.
Then:
(var-set total-gms (+ (var-get total-gms) u1))
var-set
sets the value of the input variable to the expression passed as the second parameter. Here I am performing an incremental count to increase total-gms
by one each time this function executes successfully.
Finally:
(ok "Success")
because we must return a response type at the end of a public function and I just want a success message on completion.
Manual Contract Calls
To make sure the contract is working you can do a few things.
Run:
clarinet check
This checks the contract’s syntax for errors. If all is well, it should return this message:
Next run:
clarinet console
This loads the following tables:
The first table contains the contract identifier (which is also the contract principal) and the available functions.
The second table contains 10 sets of dummy principals and balances. This data is pulled directly from the Devnet.toml
file. If you open that file and compare the standard principals, you will notice they are the same.
From here I can make some contract calls to ensure the desired functionality of each function is there.
To make a contract call, we follow the format:
(contract-call? .contract-id function-name function-params)
Make the following call to the say-gm
function:
(contract-call? .gm say-gm)
Did you get an err u100
? There is a constant ERR_STX_TRANSFER
set to err u100
. Why did the function error?
It is because I just attempted to transfer STX between the same addresses.
When running clarinet console
, tx-sender
is automatically set to the contract deployer. You can verify this by running tx-sender
from within the console. If you compare this to the data inside of Devnet.toml
you’ll see that the address is the same as the one listed under [accounts.deployer]
. It is also the first address in the table that loads when opening console.
This comes back to the “context” I mentioned above. You can think of that first address as the deployer and all subsequent addresses as “users” that can call into your function.
I’m going to want to change the tx-sender
within console. This can be done by running
::set_tx_sender address
I can copy/paste any address from the assets table or Devnet.toml
.
Now I will call get-total-gms
:
(contract-call? .gm get-total-gms)
Another error? use of unresolved contract
.
This is happening because I changed my tx-sender
. I now have to explicitly state the contract-id as part of the contract call. This can be grabbed from the assets table. If the console has cleared and you need to bring the table up again run:
::get_contracts
Now trying the following call:
(contract-call? 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.gm get-total-gms)
This should return u0
(contract-call? 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.gm get-gm tx-sender)
This should return none
Good this is expected because I have not yet called any write functions.
Alright, let’s call say-gm
.
(contract-call? 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.gm say-gm)
This now returns (ok "Success")
If we make calls again to get-total-gms
and get-gm
we should get u1
and (some "gm")
respectively which means the functionality works!
You can also run:
::get_assets_maps
This is will bring up the assets table and you will be able to see the transfer of STX between addresses reflected in the balances.
The functions have now been manually tested. Before moving on, I will describe one more thing to demonstrate the context for tx-sender
.
I mentioned the as-contract
built-in function and how it changes the context of tx-sender
from the standard principal to the contract principal.
Exit the console and replace the CONTRACT_OWNER
definition with:
(define-constant CONTRACT_OWNER (as-contract tx-sender))
Now run clarinet console
again and make the following contract-call:
(contract-call? .gm say-gm)
You’ll notice that it doesn’t return an error this time and you didn’t have to change the tx-sender
. Why?
Running
::get_assets_maps
will provide an answer.
Notice a new address has been added to the table. A contract principal. So even though the call was made from the deployer, it was able to transfer STX because CONTRACT_OWNER
was initialized as the contract and so it isn’t transferring STX between the same address, it is transferring from the deployer’s standard principal to the contract principal which is capable of holding tokens as well.
This demonstrates what the as-contract
function does.
Unit Tests
Unit testing is a critical part of smart contract development. Due to the nature of smart contract and the potential use cases, smart contract developers want to ensure that they account for as many scenarios as possible.
Tests are written in typescript and a template is auto generated during project initialization.
Let’s take a look at gm_test.ts
import { Clarinet, Tx, Chain, Account, types } from 'https://deno.land/x/clarinet@v0.31.0/index.ts';
import { assertEquals } from 'https://deno.land/std@0.90.0/testing/asserts.ts';
Clarinet.test({
name: "Ensure that <...>",
async fn(chain: Chain, accounts: Map<string, Account>) {
let block = chain.mineBlock([
/*
* Add transactions with:
* Tx.contractCall(...)
*/
]);
assertEquals(block.receipts.length, 0);
assertEquals(block.height, 2);
block = chain.mineBlock([
/*
* Add transactions with:
* Tx.contractCall(...)
*/
]);
assertEquals(block.receipts.length, 0);
assertEquals(block.height, 3);
},
});
This is the boilerplate test code. I recommend browsing the Deno documentation provided at the top the file.
I am just going to write a single basic test as part of this example.
Clarinet.test({
name: "A user can say gm",
async fn(chain: Chain, accounts: Map<string, Account>) {
const deployer = accounts.get('deployer')!.address;
const user = accounts.get('wallet_1')!.address
let block = chain.mineBlock([
Tx.contractCall(
"gm",
"say-gm",
[],
user
)
]);
assertEquals(block.receipts.length, 1);
assertEquals(block.height, 2);
const messageMapped = chain.callReadOnlyFn(
"gm",
"get-gm",
[types.principal(user)],
user
)
assertEquals(messageMapped.result, types.some(types.ascii("gm")));
const totalCountIncreased = chain.callReadOnlyFn(
"gm",
"get-total-gms",
[],
user
)
assertEquals(totalCountIncreased.result, types.uint(1));
}
});
Here I’m adding a few things to the boilerplate code provided.
const deployer = accounts.get('deployer')!.address;
const user = accounts.get('wallet_1')!.address
This is grabbing the standard principals from Devnet.toml
that correspond to the string we pass to accounts.get()
Tx.contractCall(
"gm",
"say-gm",
[],
user
)
Tx.contractCall
is how we can simulate a contract call from a test. It takes 4 parameters:
- The contract name
- The function name
- Any params accepted by the function as an array
- The address calling the function
assertEquals(block.receipts.length, 1);
assertEquals(block.height, 2);
I need to also simulate a block being mined. Our test assumes a start at genesis block 1.
block.receipts.length
accounts for the number of transactions in that block.
block.height
accounts for the block height at mining.
In this case I am calling one tx and mining one block.
const messageMapped = chain.callReadOnlyFn(
"gm",
"get-gm",
[types.principal(user)],
user
)
assertEquals(messageMapped.result, types.some(types.ascii("gm")));
Here I am calling a read-only
function with chain.callReadOnlyFn()
which takes the same parameters as Tx.contractCall()
and asserting that the result is an ascii string “gm”. Why? If the say-gm
call is successful we can assume that the result of this get-gm
will be that string mapped to the user.
const totalCountIncreased = chain.callReadOnlyFn(
"gm",
"get-total-gms",
],
user
)
assertEquals(totalCountIncreased.result, types.uint(1));
Finally, I make a call to get-total-gms
and assert that the total has been incremented to 1.
Now in the terminal I can run
clarinet test
and should see a successful pass:
Clarinet offers an extensive testing suite that includes lcov
code coverage reports.
Spinning up DevNet
The backend is complete. Let’s get DevNet running and hook it up to the web wallet so that it’s ready to go after building the frontend.
Make sure Docker is running.
In your terminal run:
clarinet integrate
With this command, Clarinet fetches the appropriate Docker images for the Bitcoin node, Stacks node, Stacks API node, Hyperchain node, and the Bitcoin and Stacks Explorers.
Boot up can take several minutes the first time you launch.
When complete, you should see something like this:
With that you’ve got DevNet running. You can read more about the interface in the docs.
There is some configuration left to do before you can interact with your frontend app.
You need to import information from your Hiro web wallet into your Devnet.toml
file.
Note: Devnet.toml
is not listed in .gitignore
meaning the information you add to the configuration may be visible, so you want to either add the file to .gitignore
, or create a separate wallet for testing if you plan to push your code to GitHub.
From your browser open up your web wallet and change your network to Devnet. This setting can be found by clicking the three dots on the top right corner and selecting Change Network.
Devnet will only be available for selection while you have your local DevNet running.
Once you change networks, open up the menu again and click View Secret Key. You need to copy this and paste it in Devnet.toml
under [accounts.wallet_1]
where it says mnemonic=
You will be replacing the generated keys with the ones copied from your web wallet.
Configuration is now complete.
Conclusion
Thanks for reading Part II of this series! Move on to Part III: Frontend.