Developing a Full-Stack Project on Stacks with Clarity Smart Contracts and Stacks.js Part II: Backend
CiaraMaria
Posted on October 6, 2022
Backend
Our contract will simply post "gm" from a user to the chain for a small fee. We will do this by mapping the string "gm" to the users 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")
)
)
That's it! The entire contract and all the functionality needed is in the above code.
We have data space at the top of the file and all of the functions listed after.
Data:
-
define-constant
: We have 3 constants defined. A constant is simply an immutable piece of data, meaning it cannot be changed once defined. In our case, we are 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. We are 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. We will be mapping a principal (Stacks wallet address) to the "gm" string.
Functions:
We have 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, we'll take a line-by-line look at the say-gm
public function.
(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
.
Our function signature is:
(define-public (say-gm))
Our 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 when we do our manual testing to give you a visual.
For now let's get back to the first 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 there we are 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, we are 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 we are 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 function and I just want a success message on completion.
Manual Contract Calls
To make sure our contract is working we'll do a few things.
Run:
clarinet check
This checks your contract's syntax for errors. If all is well, you should return this message:
Now run:
clarinet console
You should see 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 we 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 our say-gm
function:
(contract-call? .gm say-gm)
Did you get an err u100
? We set our constant ERR_STX_TRANSFER
to err u100
. So why did we get this?
It is because we just attempted to transfer STX between the same addresses.
When we run 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.
We're going to want to change the tx-sender
within console. You can do this by running
::set_tx_sender address
You can copy/paste any address from the assets table or Devnet.toml
.
Let's call get-total-gms
:
(contract-call? .gm get-total-gms)
Another error? use of unresolved contract
.
This is happening because we changed our tx-sender
we now have to explicitly state the contract-id as part of the contract call. You can grab this from the assets table. If you cleared the console and need to bring it up again run:
::get_contracts
Now try 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 we have not yet called any write functions.
Alright, let's call say-gm
.
(contract-call? 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.gm say-gm)
We should see (ok "Success")
Now 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.
Great! We've now manually tested our functions. Before moving on, we're going to do 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 your 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)
We didn't get an error this time and 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 we made the call from the deployer, we were able to transfer STX because CONTRACT_OWNER
was initialized as the contract and so we aren't transferring STX between the same address, we are 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, as smart contract developers we want to ensure that we account for as many scenarios as possible.
Tests are written in typescript and a template is auto generated for you.
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 your boilerplate test code. I recommend browsing the Deno documentation provided at the top the file.
We are just going to write a single basic test together 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 we're 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 our 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);
We 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 we are 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 we are calling a read-only
function with chain.callReadOnlyFn()
which takes the same parameters as Tx.contractCall()
and we are 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, we make a call to get-total-gms
and assert that the total has been incremented to 1.
Now in the terminal you can run
clarinet test
You should see a successful pass:
Clarinet offers an extensive testing suite that includes lcov
code coverage reports.
Curious about TDD for contracts? Check out this blog.
Spinning up DevNet
Our backend is complete! Let's get DevNet running and hook it up to our web wallet so that it's ready to go after we build 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:
Great! 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.
That's it! Configuration is complete and you're ready to start building the frontend app.
Posted on October 6, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.