Make sure you check out the Refactoring section of this tutorial. The original implementation of this contract and the tests had a couple of gaps. I kept them in and added a refactoring section to help others learn from these fixes and the process. Big thanks to lnow for helping to point these issues out and rework them.
One of the most difficult things about smart contract and web3 development can be getting an effective development environment set up.
Since the blockchain itself is a critical piece of our development infrastructure, we need to do have some way of setting up our own local instance of the blockchain we are building for.
In the Ethereum world, Hardhat is a commonly used tool for local Ethereum development with Solidity.
In the Stacks world, we have Clarinet, which accomplishes something similar.
Clarinet allows us to not only get a Clarity smart contract project set up but also to create a local Stacks chain to interact with.
This makes it trivial to begin building Stacks apps without having to deploy our smart contracts to testnet and interact with them that way.
This can significantly cut down on our development time.
Other benefits of using Clarinet include Clarity syntax checking, deploying contracts to testnet and mainnet using the CLI, writing and executing a test suite, checking our code's test coverage, and running our contract cost analysis.
The Clarinet GitHub page does a great job of laying out all the different features of Clarinet if you are curious to learn more.
Hiro also recently released a series of screencasts going over the essentials of Clarinet.
Today, we're going to look at how to use Clarinet to build a Stacks app using TDD, or test-driven development. We'll write our tests, write a Clarity smart contract to get them to pass, check out costs and explore ways to optimize them and improve on what we'll be making here.
TDD seems annoying and unnecessary until you try it, and then it's magical.
This goes double for smart contract development, where the code is permanent, public, and often controls peoples' finances and other assets.
Getting a smart contract right the first time can save a lot of headaches and potential security issues down the road. Putting in the effort upfront to test your code well and getting a professional audit are significantly better options than having a security breach and having to migrate your contract after deploying.
Using TDD allows us to map out how our smart contract should function and get a mental roadmap of how we want it to work, and then write our smart contracts in order to get those tests to pass.
This helps both for thinking through what we want our code to do and ensuring that the entirety of our Clarity contracts are covered by robust tests.
In this tutorial, we'll use Clarinet to start a new project, create tests for our smart contracts, write the smart contracts themselves, check the syntax of those contracts, check the cost and explore optimization strategies, and deploy them to testnet, all using Clarinet.
Let's get started.
What We're Building
In this tutorial, we'll be building a simple app called Cargo. Cargo is a fictional, simple Stacks app designed to help facilitate tracking the progress a shipment makes throughout the supply chain.
In this particular app, we'll track a package's travel progress using a simple map, then explore ways to improve upon the implementation presented here.
We'll also go over some next steps for you to improve upon this project on your own in order to increase your learning.
You can find the completed code for this tutorial on GitHub.
Installing Clarinet
If you don't have Clarinet installed, you'll need to do that first. I can't do a better job than the Clarinet docs at showing that so I will refer you to them.
In order to run our local Stacks chain with clarinet integrate
, you'll need to make sure you have Docker installed and running as well.
Setting Up a New Clarinet Project
Alright, let's get this thing going.
First up, let's set up our project with clarinet new cargo
and then cd
into that directory.
When you look in that directory you'll see some empty folders, contracts
and tests
, and you'll see a settings
folder with three .toml
files in it.
These files correspond to the settings for each of the three primary development environments.
Devnet.toml
corresponds to our local chain when we run clarinet integrate
.
Testnet.toml
and Mainnet.toml
correspond to the settings we want to use when we deploy to the Stacks testnet and mainnet, respectively.
Let's take a look at the Devnet.toml
file, we'll look at the other two when we deploy at the end of the tutorial.
Here's what our Devnet.toml
file looks like:
[network]
name = "devnet"
deployment_fee_rate = 10
[accounts.deployer]
mnemonic = "twice kind fence tip hidden tilt action fragile skin nothing glory cousin green tomorrow spring wrist shed math olympic multiply hip blue scout claw"
balance = 100_000_000_000_000
# secret_key: 753b7cc01a1a2e86221266a154af739463fce51219d97e4f856cd7200c3bd2a601
# stx_address: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM
# btc_address: mqVnk6NPRdhntvfm4hh9vvjiRkFDUuSYsH
[accounts.wallet_1]
mnemonic = "sell invite acquire kitten bamboo drastic jelly vivid peace spawn twice guilt pave pen trash pretty park cube fragile unaware remain midnight betray rebuild"
balance = 100_000_000_000_000
# secret_key: 7287ba251d44a4d3fd9276c88ce34c5c52a038955511cccaf77e61068649c17801
# stx_address: ST1SJ3DTE5DN7X54YDH5D64R3BCB6A2AG2ZQ8YPD5
# btc_address: mr1iPkD9N3RJZZxXRk7xF9d36gffa6exNC
[accounts.wallet_2]
mnemonic = "hold excess usual excess ring elephant install account glad dry fragile donkey gaze humble truck breeze nation gasp vacuum limb head keep delay hospital"
balance = 100_000_000_000_000
# secret_key: 530d9f61984c888536871c6573073bdfc0058896dc1adfe9a6a10dfacadc209101
# stx_address: ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AG
# btc_address: muYdXKmX9bByAueDe6KFfHd5Ff1gdN9ErG
[accounts.wallet_3]
mnemonic = "cycle puppy glare enroll cost improve round trend wrist mushroom scorpion tower claim oppose clever elephant dinosaur eight problem before frozen dune wagon high"
balance = 100_000_000_000_000
# secret_key: d655b2523bcd65e34889725c73064feb17ceb796831c0e111ba1a552b0f31b3901
# stx_address: ST2JHG361ZXG51QTKY2NQCVBPPRRE2KZB1HR05NNC
# btc_address: mvZtbibDAAA3WLpY7zXXFqRa3T4XSknBX7
[accounts.wallet_4]
mnemonic = "board list obtain sugar hour worth raven scout denial thunder horse logic fury scorpion fold genuine phrase wealth news aim below celery when cabin"
balance = 100_000_000_000_000
# secret_key: f9d7206a47f14d2870c163ebab4bf3e70d18f5d14ce1031f3902fbbc894fe4c701
# stx_address: ST2NEB84ASENDXKYGJPQW86YXQCEFEX2ZQPG87ND
# btc_address: mg1C76bNTutiCDV3t9nWhZs3Dc8LzUufj8
[accounts.wallet_5]
mnemonic = "hurry aunt blame peanut heavy update captain human rice crime juice adult scale device promote vast project quiz unit note reform update climb purchase"
balance = 100_000_000_000_000
# secret_key: 3eccc5dac8056590432db6a35d52b9896876a3d5cbdea53b72400bc9c2099fe801
# stx_address: ST2REHHS5J3CERCRBEPMGH7921Q6PYKAADT7JP2VB
# btc_address: mweN5WVqadScHdA81aATSdcVr4B6dNokqx
[accounts.wallet_6]
mnemonic = "area desk dutch sign gold cricket dawn toward giggle vibrant indoor bench warfare wagon number tiny universe sand talk dilemma pottery bone trap buddy"
balance = 100_000_000_000_000
# secret_key: 7036b29cb5e235e5fd9b09ae3e8eec4404e44906814d5d01cbca968a60ed4bfb01
# stx_address: ST3AM1A56AK2C1XAFJ4115ZSV26EB49BVQ10MGCS0
# btc_address: mzxXgV6e4BZSsz8zVHm3TmqbECt7mbuErt
[accounts.wallet_7]
mnemonic = "prevent gallery kind limb income control noise together echo rival record wedding sense uncover school version force bleak nuclear include danger skirt enact arrow"
balance = 100_000_000_000_000
# secret_key: b463f0df6c05d2f156393eee73f8016c5372caa0e9e29a901bb7171d90dc4f1401
# stx_address: ST3PF13W7Z0RRM42A8VZRVFQ75SV1K26RXEP8YGKJ
# btc_address: n37mwmru2oaVosgfuvzBwgV2ysCQRrLko7
[accounts.wallet_8]
mnemonic = "female adjust gallery certain visit token during great side clown fitness like hurt clip knife warm bench start reunion globe detail dream depend fortune"
balance = 100_000_000_000_000
# secret_key: 6a1a754ba863d7bab14adbbc3f8ebb090af9e871ace621d3e5ab634e1422885e01
# stx_address: ST3NBRSFKX28FQ2ZJ1MAKX58HKHSDGNV5N7R21XCP
# btc_address: n2v875jbJ4RjBnTjgbfikDfnwsDV5iUByw
[accounts.wallet_9]
mnemonic = "shadow private easily thought say logic fault paddle word top book during ignore notable orange flight clock image wealth health outside kitten belt reform"
balance = 100_000_000_000_000
# secret_key: de433bdfa14ec43aa1098d5be594c8ffb20a31485ff9de2923b2689471c401b801
# stx_address: STNHKEPYEPJ8ET55ZZ0M5A34J0R3N5FM2CMMMAZ6
# btc_address: mjSrB3wS4xab3kYqFktwBzfTdPg367ZJ2d
[devnet]
disable_bitcoin_explorer = true
# disable_stacks_explorer = true
# disable_stacks_api = true
# working_dir = "tmp/devnet"
# stacks_node_events_observers = ["host.docker.internal:8002"]
# miner_mnemonic = "twice kind fence tip hidden tilt action fragile skin nothing glory cousin green tomorrow spring wrist shed math olympic multiply hip blue scout claw"
# miner_derivation_path = "m/44'/5757'/0'/0/0"
# orchestrator_port = 20445
# bitcoin_node_p2p_port = 18444
# bitcoin_node_rpc_port = 18443
# bitcoin_node_username = "devnet"
# bitcoin_node_password = "devnet"
# bitcoin_controller_port = 18442
# bitcoin_controller_block_time = 30_000
# stacks_node_rpc_port = 20443
# stacks_node_p2p_port = 20444
# stacks_api_port = 3999
# stacks_api_events_port = 3700
# bitcoin_explorer_port = 8001
# stacks_explorer_port = 8000
# postgres_port = 5432
# postgres_username = "postgres"
# postgres_password = "postgres"
# postgres_database = "postgres"
# bitcoin_node_image_url = "quay.io/hirosystems/bitcoind:devnet"
# stacks_node_image_url = "localhost:5000/stacks-node:devnet"
# stacks_api_image_url = "blockstack/stacks-blockchain-api:latest"
# stacks_explorer_image_url = "blockstack/explorer:latest"
# bitcoin_explorer_image_url = "quay.io/hirosystems/bitcoin-explorer:devnet"
# postgres_image_url = "postgres:alpine"
# Send some stacking orders
[[devnet.pox_stacking_orders]]
start_at_cycle = 3
duration = 12
wallet = "wallet_1"
slots = 2
btc_address = "mr1iPkD9N3RJZZxXRk7xF9d36gffa6exNC"
[[devnet.pox_stacking_orders]]
start_at_cycle = 3
duration = 12
wallet = "wallet_2"
slots = 1
btc_address = "muYdXKmX9bByAueDe6KFfHd5Ff1gdN9ErG"
[[devnet.pox_stacking_orders]]
start_at_cycle = 3
duration = 12
wallet = "wallet_3"
slots = 1
btc_address = "mvZtbibDAAA3WLpY7zXXFqRa3T4XSknBX7"
There's a lot going on here, but it's actually pretty simple. All we are doing is declaring the name of the network, setting the amount of STX we want to pay to deploy a contract on it, and then instantiating a bunch of different principals (Stacks addresses) with some test STX.
The first one is the deployer address and the rest are just other accounts we can use to test our contracts.
At the bottom we are setting up a bunch of stuff that will determine how our local chain will actually run.
Most of these are ports and postgres configurations that you'll never need to touch, but if you ever do, now you know where they are.
We also have a config item at the very bottom that is setting up one of our wallets to be a stacker, which will automatically set our chain up with a PoX workflow with accounts 1, 2, and 3 stacking their STX tokens and earning BTC, all happening on our local Clarinet chain.
Creating a Smart Contract
Let's create our Cargo smart contract with clarinet contract new cargo
.
This will create both the smart contract Clarity contract itself in the contracts
folder as well as the test file in the tests
folder.
Writing Tests
The first thing we are going to do is write a failing test describing what we want our contract to do.
The generated test file took care of all the boilerplate setup for us, so all we need to do is write our actual tests.
Here's what you should have in that cargo_test.ts
file right now:
import { Clarinet, Tx, Chain, Account, types } from 'https://deno.land/x/clarinet@v0.14.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 starter test file is already doing quite a bit for us. Up at the top we are importing the Clarinet Deno testing library.
I recommend going through that actual file to get a handle on the different things this testing library allows us to do.
The Clarinet
class is our actual test runner, and we are calling test
to initiate the test process.
In there we pass in our test name and function. We'll create a new one of these for each test we run. In this function we are passing chain
and accounts
which will handle setting up a mocknet Stacks chain and our set of mock accounts for testing purposes. This corresponds to the same list of accounts from that Devnet.toml
file we saw earlier.
Then within the function we are mining two blocks and checking for the correct block height after mining. We can add transactions within individual blocks where we see the TX.contractCall(..)
comment.
But what are the block.receipts
? Whenever we mine a block on the Stacks chain, a receipt is generated for each transaction that was processed in that block that gives us information about the transaction.
Since we aren't processing any transactions in these blocks yet, we have no receipts.
If we run clarinet test
right now everything should pass, since we aren't checking for much other than our mocknet for our test suite running and mining successfully.
Before we get started writing our tests, let's think about what we need our smart contract to do and outline a couple of test cases.
For this simple application, we're really only going to have one main piece of functionality, and that will be updating a package's shipping status.
But even this one use case brings up a couple of test cases. We'll likely want to make sure only certain addresses can update shipping status.
Maybe we can keep a simple map of each shipment where we store the shipper as a principal
type along with all the other shipment data in a tuple.
There is actually a more efficient way to do this, but we'll implement it this way in this tutorial and explore a possible refactor at the end.
So here are the test cases we might need for this:
- A user should be able to successfully create a new shipment
- The shipper should be able to successfully update their shipment status
- A user should not be able to update another shipper's shipment status
- A user should be able to read the current status of a shipment
I like to phrase my test cases in human-readable form, in a user story-ish format, describing what the functionality is actually doing, rather than defining the specific technical implementation in the test case name.
I find that this way, it's easier to wrap our heads around what the contract should be doing, then we can change the technical implementation as needed without needing to change the test case itself.
Remember that the blockchain is public by default, so all of the shipping statuses of all packages will be publicly viewable, hence the last test case is generic and applies to all users.
If we wanted to make this data private, we would need to change up our approach a bit, something we'll explore further down below when we look at optimizing costs.
We aren't going to be storing a history of all our shipment updates directly within our map in our smart contract. Why not?
Again, this is something that requires a shift in thinking from traditional web2 apps. The blockchain itself is, by design, a historical record of all transactions. So we don't need to store this history in our contract directly, Stacks does it for us.
Clarity has a built-in at-block
function that changes the context of whatever expression is passed to it to that particular block.
So we can pull our shipment status map data from any previous block by passing in the block number and pulling the relevant map data.
We won't be implementing this particular piece of functionality in this tutorial, but here's what it could look like if you want to explore it on your own.
We could store the block height of when the shipment was created and when it arrived and use that to create a range of time when the shipment was in transit.
In our UI, we could take these block heights and extrapolate out time estimates from those and allow the user to look up what the status was at a certain point in time.
Alright let's get to writing our tests.
First up:
A user should be able to successfully create a new shipment
Here's the test for this particular user story:
Clarinet.test({
name: "A user should be able to successfully create a new shipment",
async fn(chain: Chain, accounts: Map<string, Account>) {
const shipper = accounts.get('wallet_1')!.address;
const receiver = accounts.get('wallet_2')!.address
let block = chain.mineBlock([
// Call the create-new-shipment function, passing in the starting location as the only parameter
Tx.contractCall(
'cargo',
'create-new-shipment',
[types.ascii('Denver'), types.principal(receiver)],
shipper
)
]);
console.log(types)
const result = block.receipts[0].result;
// Check for the success message
result.expectOk().expectAscii('Shipment created successfully')
},
});
First we create the test and give it a name. I like to name them based on the user stories created above.
Then we are creating the actual test function itself and passing in the chain and accounts. These are generated by Clarinet and allow us to actually interact with our testing mocknet.
Inside the function we initiate mining a block with the chain.mineBlock
function, and inside that is where we call our contract, thus initiating a transaction, with Tx.contractCall
.
We need to pass some parameters to tell the contractCall
function what to actually call.
First we pass the name of our contract, cargo
.
Then the name of the function we want to call within that contract, create-new-shipment
.
Then we pass in any parameters the function needs. In this case, we need to pass in a starting location to the function and a receiver address, which has been defined above the block creation.
Finally we pass in the principal of the transaction sender, again defined up above. In our case we are using the second generated wallet address.
You can see the structure of the accounts
map by adding a simple console.log(accounts)
statement if you are curious how those are structured.
Note the types.ascii
and types.principal
functions we are passing our arguments to. That needs to be the same type that our Clarity function accepts in our contract.
Now if we run clarinet test
this will of course fail because we don't even have that function to call.
In this case it is failing because we have no transaction receipt, because our transaction isn't actually doing anything yet.
Let's jump over to our contract and write this function.
One thing to remember when doing TDD is that we only want to add functionality incrementally as we create tests.
So right now we are only adding enough to this contract to get this test to pass. We'll add additional functionality like auth checking as we write more failing tests.
Let's define our first public function.
;; cargo
;; a simple decentralized shipment tracker
;; constants
;; data maps and vars
(define-data-var last-shipment-id uint u0)
(define-map shipments uint {location: (string-ascii 25), status: (string-ascii 25), shipper: principal, receiver: principal})
;; private functions
;;
;; public functions
(define-public (create-new-shipment (starting-location (string-ascii 25)) (receiver principal))
(let
(
(new-shipment-id (+ (var-get last-shipment-id) u1))
)
;; #[filter(starting-location, receiver)]
(map-set shipments new-shipment-id {location: starting-location, status: "In Transit", shipper: tx-sender, receiver: receiver})
(ok "Shipment created successfully")
)
)
The first thing we are doing here is defining a data-var
and a map
in order to keep track of our shipment ids and our shipment data.
Then we are creating the actual function that will handle creating this new shipment. You can see that the parameters that we are accepting here match up with the parameters we passed in our test.
We are first setting the value of our new id, and then adding this particular item to our map. After we run the map-set
then we return an ok
response with the expected test.
Run clarinet test
again and our test should pass.
What's this ;; #[filter(starting-location, receiver)]
line?
This is another feature of Clarinet called the check-checker that warns us about any untrusted input in our contract.
You can read more about it here but in our case we want the user to be able to pass in whatever they want for the starting-location
and receiver
fields, so we are telling the check checker to filter out these particular variables when checking for untrusted input.
Syntax Checking
One of the other very helpful features of Clarinet that you may have noticed is syntax checking.
Throughout the process of developing our smart contract, Clarinet will help us to make sure we are formatting things correctly and have the right syntax.
This can help greatly in our workflow and make it so we can write better contracts faster.
There are two ways to check the syntax. You can run clarinet check
in the main Clarinet folder.
Or if you use VS Code, you can use the Clarity extension to automatically run syntax checks when you save your files.
Now, let's get back to building out our tests. We have the first one passing, and have confirmed that a user can successfully create a new shipment.
Let's add our next test case.
The shipper should be able to successfully update their shipment status
Here's what test for this particular functionality would look like:
Clarinet.test({
name: "A user should be able to update their shipment",
async fn(chain: Chain, accounts: Map<string, Account>) {
const shipper = accounts.get('wallet_1')!.address;
let block = chain.mineBlock([
// Call the update-shipment function, passing in the shipment id and current location
Tx.contractCall(
'cargo',
'update-shipment',
[types.uint(1), types.ascii('Phoenix')],
shipper
)
]);
const result = block.receipts[0].result;
// Check for the success message
result.expectOk().expectAscii('Shipment updated successfully')
},
});
This is very similar to the first test, except we are calling an update function and passing in a couple of different parameters to find the correct shipment and to update the location.
Running clarinet test
results in a failing test, so let's write that new function.
(define-public (update-shipment (shipment-id uint) (current-location (string-ascii 25)))
(let
(
(previous-shipment (unwrap! (map-get? shipments shipment-id) err-shipment-not-found))
(new-shipment-info (merge previous-shipment {location: current-location}))
)
;; #[filter(shipment-id)]
(map-set shipments shipment-id new-shipment-info)
(ok "Shipment updated successfully")
)
)
We are also adding a constant for our error message up top:
;; constants
(define-constant err-shipment-not-found (err u100))
Here we are first getting the existing shipment, and returning an error if it doesn't exist. We don't want to add a new shipment here.
Then we are creating a new tuple of the shipment info by merging the existing info with the new location.
Tuples are immutable once created, so we need to create a new tuple but with only the relevant information updated.
Then we are taking that information and using it to update the shipments map using the corresponding shipment id that was passed into the function and returning a success message.
If we run this now, it will fail with our shipment-not-found-error
.
That's because in our test case, we aren't creating a new shipment before attempting to update it. So we need to add in some code to create a new shipment.
Let's update this test to first create a new shipment in the first block, then update it in the second block.
Clarinet.test({
name: "A user should be able to update their shipment",
async fn(chain: Chain, accounts: Map<string, Account>) {
const shipper = accounts.get('wallet_1')!.address;
const receiver = accounts.get('wallet_2')!.address
let block = chain.mineBlock([
// Call the create-new-shipment function, passing in the starting location as the only parameter
Tx.contractCall(
'cargo',
'create-new-shipment',
[types.ascii('Denver'), types.principal(receiver)],
shipper
)
]);
assertEquals(block.receipts.length, 1);
assertEquals(block.height, 2);
block = chain.mineBlock([
// Call the update-shipment function, passing in the shipment id and current location
Tx.contractCall(
'cargo',
'update-shipment',
[types.uint(1), types.ascii('Phoenix')],
shipper
)
]);
const result = block.receipts[0].result;
// Check for the success message
result.expectOk().expectAscii('Shipment updated successfully')
},
});
Now if we run clarinet test
again, our test passes.
This has however brought up an additional use case we are not currently testing for. You'll notice this workflow as you practice TDD.
You'll do your best to create the best test cases at the beginning, but they will evolve as you build out your project.
We should a test to check and make sure that a user cannot update a shipment that doesn't exist. Because of the way map-set works, this would create a new shipment. We don't want that.
Our code actually does this already, but we should add in a test case to make sure that functionality doesn't break as we refactor.
Clarinet.test({
name: "A user should not be able to update a shipment that does not exist",
async fn(chain: Chain, accounts: Map<string, Account>) {
const shipper = accounts.get('wallet_1')!.address;
const receiver = accounts.get('wallet_2')!.address
let block = chain.mineBlock([
// Call the create-new-shipment function, passing in the starting location as the only parameter
Tx.contractCall(
'cargo',
'create-new-shipment',
[types.ascii('Denver'), types.principal(receiver)],
shipper
)
]);
assertEquals(block.receipts.length, 1);
assertEquals(block.height, 2);
block = chain.mineBlock([
// Call the update-shipment function, passing in the shipment id and current location
Tx.contractCall(
'cargo',
'update-shipment',
[types.uint(5), types.ascii('Phoenix')],
shipper
)
]);
const result = block.receipts[0].result;
// Check for the correct message defined in our constants
result.expectErr().expectUint(100)
},
});
This is very similar to the previous test case except we are passing in the id of a shipment that does not exist and checking that our function returns the correct error response.
This test will pass, since we built that functionality into our contract, but let's make sure it works by creating a failing test case.
Change the id of 5
to a 1
and the test should fail because the contract call succeeds.
Be sure to change it back before moving on to the next test case.
A user should not be able to update another shipper's shipment status
As it stands now, anybody can update any shipment, regardless of whether or not they are the shipper.
We don't want that. Only the shipper should be able to update the status. In a real-world application, we may want to have a collection of allowed addresses that can update the shipment, rather than only the shipper being able to update it.
We could do that by passing in a list of allowed addresses when we first create the shipment, and using the index-of function to see if the tx-sender
is in that list.
I leave that as an exercise for the reader. For now, we will compare to a single principal, the shipper
.
Here's the next test case:
Clarinet.test({
name: "A user should not be able to update another shipper's shipment status",
async fn(chain: Chain, accounts: Map<string, Account>) {
const shipper = accounts.get('wallet_1')!.address;
const receiver = accounts.get('wallet_2')!.address
const stranger = accounts.get('wallet_3')!.address
let block = chain.mineBlock([
// Call the create-new-shipment function, passing in the starting location as the only parameter
Tx.contractCall(
'cargo',
'create-new-shipment',
[types.ascii('Denver'), types.principal(receiver)],
shipper
)
]);
assertEquals(block.receipts.length, 1);
assertEquals(block.height, 2);
block = chain.mineBlock([
// Call the update-shipment function, passing in the shipment id and current location
// This should fail since it is being called by a stranger and not the shipper
Tx.contractCall(
'cargo',
'update-shipment',
[types.uint(1), types.ascii('Phoenix')],
stranger
)
]);
const result = block.receipts[0].result;
// Check for the correct error message defined in our constants
result.expectErr().expectUint(101)
},
});
Again, very similar to a previous test except we are instead passing in the stranger
as the tx-sender
, which corresponds to a different address on the chain.
And we are checking for an error of u101
when the transaction attempts to execute.
Our test should fail now because we are successfully updating the status, rather than erroring out.
Let's refactor our contract a bit to get this test to pass.
The two main things we need to add are a new error constant and an asserts!
statement checking to see if the tx-sender
is equal to the shipper
.
Here's what our full contract looks like after making those changes.
;; cargo
;; a simple decentralized shipment tracker
;; constants
(define-constant err-shipment-not-found (err u100))
(define-constant err-tx-sender-unauthorized (err u101))
;; data maps and vars
(define-data-var last-shipment-id uint u0)
(define-map shipments uint {location: (string-ascii 25), status: (string-ascii 25), shipper: principal, receiver: principal})
;; private functions
;;
;; public functions
(define-public (create-new-shipment (starting-location (string-ascii 25)) (receiver principal))
(let
(
(new-shipment-id (+ (var-get last-shipment-id) u1))
)
;; #[filter(starting-location, receiver)]
(map-set shipments new-shipment-id {location: starting-location, status: "In Transit", shipper: tx-sender, receiver: receiver})
(ok "Shipment created successfully")
)
)
(define-public (update-shipment (shipment-id uint) (current-location (string-ascii 25)))
(let
(
(previous-shipment (unwrap! (map-get? shipments shipment-id) err-shipment-not-found))
(shipper (get shipper previous-shipment))
(new-shipment-info (merge previous-shipment {location: current-location}))
)
(asserts! (is-eq tx-sender shipper) err-tx-sender-unauthorized)
;; #[filter(shipment-id)]
(map-set shipments shipment-id new-shipment-info)
(ok "Shipment updated successfully")
)
)
We've added a new error constant along with a check to make sure the sender of this transaction is the shipper of the shipment being edited.
Note the addition of the shipper
variable in the let
function where we get that information.
Run this test again and it should pass. Let's add the last test case.
A user should be able to read the current status of a shipment
This one is pretty straightforward. We simply want to make sure that we can easily read the shipment status of a shipment with a given id.
We'll set up a new test to call a read-only
function and then get that function written.
First, the test:
Clarinet.test({
name: "A user should be able to read the current status of a shipment",
async fn(chain: Chain, accounts: Map<string, Account>) {
const shipper = accounts.get('wallet_1')!.address;
const receiver = accounts.get('wallet_2')!.address
let block = chain.mineBlock([
// Call the create-new-shipment function, passing in the starting location as the only parameter
Tx.contractCall(
'cargo',
'create-new-shipment',
[types.ascii('Denver'), types.principal(receiver)],
shipper
)
]);
// Get the shipment information with an ID of 1
const newShipment = chain.callReadOnlyFn(
'cargo',
'get-shipment',
[types.uint(1)],
receiver
)
// Now we want to check and see if this returns the shipment tuple we are expecting
const expectedShipment = newShipment.result
expectedShipment.expectOk()
assertEquals(expectedShipment, `(ok {location: "Denver", receiver: ${receiver}, shipper: ${shipper}, status: "In Transit"})`)
},
});
All we are doing at the bottom is checking that we get an ok
response, and that it has the data we are expecting.
Right now when we run this it will fail because that function does not exist.
Let's create it.
(define-read-only (get-shipment (shipment-id uint))
(ok (unwrap! (map-get? shipments shipment-id) err-shipment-not-found))
)
This is a simple read-only
function that will take in a shipment id and attempt to grab the entry in our shipments map with that id.
The unwrap!
function will attempt to grab that value and return the not found error we created if it doesn't exist.
Now when we run the test again it should pass.
Code Coverage
One more thing we can do before moving on is check to see how much of our code is covered by tests.
We can do this in just a few commands. Clarinet uses lcov to produce code coverage reports.
If you don't have that installed yet, you can do so on Mac with brew install lcov
.
If you have Windows, lcov is available as a Chocolatey package.
Now, from inside your Clarinet directory, run clarinet test --coverage
.
Once that finishes, you can generate the file with genhtml coverage.lcov
and open it with open index.html
.
According to this report we have 100% code coverage. Nice.
Refactoring
The astute reader may notice a couple of problems with the way the tests have been written so far, and an error with the contract itself.
Big thanks to lnow for pointing these issues out and helping work to write more robust tests.
There's one critical error with the way I've written the tests here. Because they are only checking for return statuses, and not for data actually changing on-chain, we can only verify they work on a surface level.
For example, this contract would actually cause all our tests to pass, which is obviously not what we want.
(define-public (create-new-shipment (starting-location (string-ascii 25)) (receiver principal) )
(ok "Shipment created successfully")
)
(define-public (update-shipment (shipment-id uint) (current-location (string-ascii 25)))
(begin
(asserts! (not (is-eq shipment-id u5)) (err u100))
(asserts! (not (is-eq tx-sender 'ST2JHG361ZXG51QTKY2NQCVBPPRRE2KZB1HR05NNC)) (err u101))
(ok "Shipment updated successfully")
)
)
(define-read-only (get-shipment (shipment-id uint))
(ok {
location: "Denver",
receiver: 'ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AG,
shipper: 'ST1SJ3DTE5DN7X54YDH5D64R3BCB6A2AG2ZQ8YPD5,
status: "In Transit"
})
)
So we need to make a couple of adjustments to our tests to check for on-chain data.
We already have the piece we need to do this in our get-shipment
function. So we need to add this and check what it returns as part of our tests to make sure shipments have been updated and added correctly.
That piece of code we use to check that we can successfully get a shipment from the chain? We need to add that at the bottom of our create-shipment
test:
// Check that a shipment was created
const newShipment = chain.callReadOnlyFn(
'cargo',
'get-shipment',
[types.uint(1)],
receiver
)
// Now we want to check and see if this returns the shipment tuple we are expecting
const expectedShipment = newShipment.result
expectedShipment.expectOk()
assertEquals(expectedShipment, `(ok {location: "Denver", receiver: ${receiver}, shipper: ${shipper}, status: "In Transit"})`)
And our update-shipment
test:
// Check that a shipment was updated
const newShipment = chain.callReadOnlyFn(
"cargo",
"get-shipment",
[types.uint(1)],
receiver
);
// Now we want to check and see if this returns the shipment tuple we are expecting
const expectedShipment = newShipment.result;
expectedShipment.expectOk();
assertEquals(
expectedShipment,
`(ok {location: "Phoenix", receiver: ${receiver}, shipper: ${shipper}, status: "In Transit"})`
);
Now we have some better tests that are actually checking that the things we want to happen on-chain are happening.
But we have another issue.
Right now, we only have tests to check if we can create a single shipment successfully, which means our tests have no way of verifying whether or not our shipment ID incrementer works correctly (spoiler alert: we never added it 🙃).
So let's first add a failing test to create multiple shipments.
Clarinet.test({
name: "Multiple users should be able to successfully create multiple shipments",
async fn(chain: Chain, accounts: Map<string, Account>) {
const firstShipper = accounts.get("wallet_1")!.address;
const firstReceiver = accounts.get("wallet_2")!.address;
const firstLocation = "Denver";
let block = chain.mineBlock([
// Call the create-new-shipment function, passing in the starting location as the only parameter
Tx.contractCall(
"cargo",
"create-new-shipment",
[types.ascii(firstLocation), types.principal(firstReceiver)],
firstShipper
),
]);
const firstResult = block.receipts[0].result;
// Check for the success message
firstResult.expectOk().expectAscii("Shipment created successfully");
// Check that a shipment was created
const firstShipment = chain.callReadOnlyFn(
"cargo",
"get-shipment",
[types.uint(1)],
firstReceiver
);
// Now we want to check and see if this returns the shipment tuple we are expecting
const firstExpectedShipment = firstShipment.result;
firstExpectedShipment.expectOk();
assertEquals(
firstExpectedShipment,
`(ok {location: "${firstLocation}", receiver: ${firstReceiver}, shipper: ${firstShipper}, status: "In Transit"})`
);
// Now let's create another shipment and perform the same checks
const secondShipper = accounts.get("wallet_3")!.address;
const secondReceiver = accounts.get("wallet_4")!.address;
const secondLocation = "New York";
block = chain.mineBlock([
// Call the create-new-shipment function, passing in the starting location as the only parameter
Tx.contractCall(
"cargo",
"create-new-shipment",
[types.ascii(secondLocation), types.principal(secondReceiver)],
secondShipper
),
]);
const secondResult = block.receipts[0].result;
// Check for the success message
secondResult.expectOk().expectAscii("Shipment created successfully");
// Check that a shipment was created
const secondShipment = chain.callReadOnlyFn(
"cargo",
"get-shipment",
[types.uint(2)],
secondReceiver
);
// Now we want to check and see if this returns the shipment tuple we are expecting
const secondExpectedShipment = secondShipment.result;
secondExpectedShipment.expectOk();
assertEquals(
secondExpectedShipment,
`(ok {location: "${secondLocation}", receiver: ${secondReceiver}, shipper: ${secondShipper}, status: "In Transit"})`
);
},
});
If we run clarinet test
again this will fail with an err u100
If we check our contract, that corresponds to the shipment not found error.
Why is this happening?
If you look in our create-new-shipment
function, we are getting the last-shipment-id
but we are never updating that value when we update our map, so it will always get set to 1.
Let's fix that by modifying the function and then re-run our test.
We only need to add one line to make this work, add this right above the ok
return.
(var-set last-shipment-id new-shipment-id)
And with that, our tests pass.
Alright, we have all our main tests and functionality written. But the way we store data here could be improved. Let's look at how we might refactor this while using Clarinet's cost optimization functions.
Cost Optimization
One thing to keep in mind when writing data to a blockchain via a smart contract is that we want to keep that amount of data to a minimum. Users pay for every byte of data.
Clarinet has a cost analysis function built into the testing harness, but there are currently some issues with the way it works.
Until those get sorted out, we'll need to analyze the costs of our functions manually using the console.
Another huge shoutout to lnow for pointing these out to me and helping me work through how to run manual cost analysis.
I very highly recommend reading through that GitHub issue to understand what the problem is and the discrepancy between running ::get_costs
and clarinet test --costs
.
Let's run a quick cost analysis by opening up the console with clarinet console
.
We'll run a cost analysis on our create-shipment
function and pass in some data.
::get_costs (contract-call? .cargo create-new-shipment "Denver" 'ST1SJ3DTE5DN7X54YDH5D64R3BCB6A2AG2ZQ8YPD5)
Remember your address will be different, I just used the first one generated when I entered the console.
We'll get our success output and a rundown of what it cost, it should look something like this. This is telling us how many bytes were consumed by each type of action on the left, and the upper limit of what can be done on the right.
The goal is to get the cost down as much as possible so we can have more transactions per block and cut down on write costs of writing data to the chain.
Remember when I said above that writing all that data into a map was not the best way to handle this?
This is why. We want to avoid writing that much data if possible.
Pay particular attention to the Write length (bytes)
column.
This is just a sample app, in a real-world shipment tracker we would likely need a lot more data to be stored in that map.
A better option here would be to hash this data, store it in a traditional database, and use the on-chain hash of the data as the source of truth. That way we can compare the hash of the centralized data to the hash stored on-chain to maintain security and trustlessness without needing to store the data directly on-chain.
Another benefit of this is that we can keep data private while still verifying it is accurate. If we wanted the data to be public we could implement some sort of publicly viewable dashboard that showed the hash of the current data and that it was the same as what is on chain.
All of the UI code responsible for fetching the data and comparing the hash could be made open source so it is publicly viewable and verifiable. Users could determine for themselves if the data is to be trusted and act accordingly.
Remember, transparency is not necessarily the problem we are trying to solve for when building smart contracts. Transparency is easily solved with open source code.
We are trying to make something transparent and immutable/incorruptible. But that comes with tradeoffs, like every transaction having a cost.
We can create applications that are the best of both worlds by writing smart contracts that store a minimal amount of data while still keeping integrity.
This also makes it significantly easier to update our data structure in the future. It's out of the scope of this tutorial, but you can read more about this approach in the Clarity Book.
If you've read my first tutorial on Stacks development, you know I'm a fan of going out and practicing things on your own.
This is an excellent opportunity to do that. I have a few ideas for how you can flex your Stacks muscles in the "Practice on Your Own" section below, with some guidance.
But first, let's get this deployed to testnet using Clarinet.
Deploying
Clarinet makes deploying Clarity contracts very easy.
In fact, we literally only need one command:
clarinet publish --testnet
But before this will work we need to edit the Testnet.toml
file in order to add our testnet address mnemonic so Clarinet knows what address to deploy with.
You can find this in the Hiro wallet by switching to testnet, selecting the address you want to deploy with, hitting the menu with the three dots, and selecting 'View Secret Key'.
Copy the memonic that shows there and paste it in the right spot in the Testnet.toml
file.
[network]
name = "testnet"
node_rpc_address = "https://stacks-node-api.testnet.stacks.co"
deployment_fee_rate = 10
[accounts.deployer]
mnemonic = "<YOUR PRIVATE TESTNET MNEMONIC HERE>"
Then you can go to the explorer and copy the tx id that was generated to view your deployed contract. You can see mine here.
Practice On Your Own
There are two major improvements we could make to our project as it stands right now, implementing hashing as discussed above to reduce contract costs and adding a UI.
Implementing the Hash Function
Here are the steps you would need to take to make this happen. Implementing this on your own would be great practice.
First, Modify the testing file to create the data structure in the form of an object and pass it to a function that will sha256 hash it, such as js-sha256.
You'll need to modify all of the test functions in order to correctly pass in this new data to the create and update functions.
Next you'll need to remove the get-shipment
function and replace it with something that compares the data hash generated via JS with the one stored on chain.
Finally, you'll need to modify the Clarity functions to instead take in the data hash as a parameter and get the tests to pass.
In a real app, our tests would be mimicking us saving our data to an off-chain DB and then passing the hash of that saved data to our smart contract.
For more information on what this might look like, check out the Best Practices chapter of the Clarity Book.
Adding a UI and DB
We've got some smart contracts that are covered by tests now, but they aren't very useful if nobody can interact with them.
You've got a fully functioning smart contract with Clarinet, let's try adding a frontend and database to it.
This is something to try on your own to exercise your Stacks muscles and start learning how to build useful full-stack apps.
The concept of storing hashes of data instead of the data itself is super important to familiarize yourself with. It's important to remember that decentralized web development is different than traditional web development. We need to think about things differently.
Storing a hash of our data on the blockchain while storing the data itself in a traditional centralized database still allows us to maintain public, transparent, verifiable data integrity while keeping the benefits of centralized data storage, such as speed, easy upgradeability, and data privacy.
If we needed to add other data field to our shipment info down the road, this would be a major task if all this data were stored on the blockchain, but it's trivial with traditional data storage.
So, try this out for a project:
Utilizing Next.js, Tailwind CSS, Supabase, and Micro-Stacks, create a frontend for the smart contracts we wrote here.
This will likely be challenging, but will be an excellent exercise to practice the art of full-stack development with Stacks.
Be sure to tag me in the Stacks Discord if you need any help, kennny#0001.
Good luck!
Wrap Up and Next Steps
In this tutorial we've taken a brief look at how you can use Clarinet to aid in your Stacks development.
We've looked at testing, syntax checking, cost optimizations, and deployment.
But as always, this information won't really sink in unless you go out and practice it on your own. I've given you a few ideas to get your gears turning, but feel free to experiment and see how all this works for yourself.
I'm always available for help if you need anything on Discord and Twitter.
And if you haven't yet, be sure to check out start.stacks.org for a complete roadmap to get started in Stacks development.
Top comments (0)