DEV Community

Cover image for Developing a Full-Stack Project on Stacks with Clarity Smart Contracts and Stacks.js Part II: Backend
CiaraMaria
CiaraMaria

Posted on

Developing a Full-Stack Project on Stacks with Clarity Smart Contracts and Stacks.js Part II: Backend

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")
  )
)
Enter fullscreen mode Exit fullscreen mode

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")
  )
)
Enter fullscreen mode Exit fullscreen mode

A Clarity custom function takes the following form:

(define-public function-signature function-body)
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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")
  )
Enter fullscreen mode Exit fullscreen mode

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 use begin to evaluate multiple expressions in a single function.

Next we have:

(unwrap! (stx-transfer? PRICE tx-sender CONTRACT_OWNER) ERR_STX_TRANSFER)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

So there we are unwrapping the result of stx-transfer?

Now we have:

(map-set UserPost tx-sender "gm")
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

This checks your contract's syntax for errors. If all is well, you should return this message:

Image description

Now run:

clarinet console
Enter fullscreen mode Exit fullscreen mode

You should see the following tables:

Image description

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)
Enter fullscreen mode Exit fullscreen mode

Make the following call to our say-gm function:

(contract-call? .gm say-gm)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Image description

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
Enter fullscreen mode Exit fullscreen mode

Now try the following call:

(contract-call? 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.gm get-total-gms)
Enter fullscreen mode Exit fullscreen mode

This should return u0

(contract-call? 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.gm get-gm tx-sender)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

Now run clarinet console again and make the following contract-call:

(contract-call? .gm say-gm)
Enter fullscreen mode Exit fullscreen mode

We didn't get an error this time and didn't have to change the tx-sender. Why?

Running

::get_assets_maps
Enter fullscreen mode Exit fullscreen mode

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);
    },
});
Enter fullscreen mode Exit fullscreen mode

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));
    }
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
)
Enter fullscreen mode Exit fullscreen mode

Tx.contractCall is how we can simulate a contract call from our test. It takes 4 parameters:

  1. The contract name
  2. The function name
  3. Any params accepted by the function as an array
  4. The address calling the function
assertEquals(block.receipts.length, 1);
assertEquals(block.height, 2);
Enter fullscreen mode Exit fullscreen mode

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")));
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

You should see a successful pass:

Image description

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
Enter fullscreen mode Exit fullscreen mode

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:

Image description

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.

Image description

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.

Top comments (0)