Completed code on GitHub here. Corrections/suggestions welcome :)
NOTE: Since this space moves so quickly, a lot of the libraries used are shifting underneath this tutorial.
If you see something that isn't working, you are probably using the newest version of a contract or library. Tweaks are likely necessary!
NOTENOTE: These tutorials were written using Nile. Since then, however, a foundry-esque testing framework called protostar has been released. I would urge most people to use this, as the benefits of testing in the same language as your contracts is not to be understated.
In this series, we'll create a primitive smart contract, learning all of the building blocks you'll need to get started writing your own real world dapps on StarkNet. The application will be a "blackbox" for cars. Similar to those found on airplanes, recording flight statistics and diagnostics, we'll make one for standard automobiles.
Most modern vehicles have an OBD2 port where owners or mechanics can read information about the vehicle – anything from mileage, to speed, to throttle position. A raspberry pi will read this data, along with dashcam footage, and periodically post hashes of the recent segments on StarkNet. The actual diagnostics data will be stored offline, but able to be rehashed & verified later if needed.
If the owner is in a car accident or pulled over, they will have timestamped proof of their data on chain, providing more detail to the process of "traffic collision reconstruction" or disputing a traffic violation. Sure, it's possible for this data to be spoofed between the time its recorded and committed on chain. Though, it's hard to know what exactly you'll need to lie about in the future, and – with a sufficiently small interval – it would be unlikely that a coherent lie could be spoofed, posted on chain, and make sense within the broader context of the incident.
Plus, this is just an example :)
⚠ WARNING: All code used & linked to in this tutorial is experimental and not ready for production use. StartNet is new, evolving, and will inevitably see contract exploits similar to the original DAO re-entrancy bug.
Feel free to reach out with comments, suggestions, or corrections
NOTE: Raspberry pi & hardware not actually needed. Feel free to do so if you want!
We'll start with two general components, in their own folders for cleaner separation of concerns:
sam@sam:~/fullstack-starknet/part1$ tree -L 1
.
├── pi # the application running on a raspberry pi
└── starknet # the contracts deployed on starknet
Let's build the application deployed on StarkNet, since thats just more fun :)
In later parts of this series, we'll build out the rest more fully.
First, we'll need to initialize project with Nile. Using the python module venv
, create a workspace for your cairo code and install the Nile project manager:
sam@sam:~/fullstack-starknet/part1/starknet$ python3 -m venv env
sam@sam:~/fullstack-starknet/part1/starknet$ source env/bin/activate
(env) sam@sam:~/fullstack-starknet/part1/starknet$ python3 -m pip install cairo-nile
A simple nile init
will bootstrap the project's structure:
(env) sam@sam:~/fullstack-starknet/part1/starknet$ nile init
...
✅ Dependencies successfully installed
🗄 Creating project directory tree
⛵️ Nile project ready! Try running:
nile compile
Inspecting the generated file hierarchy, we now have:
(env) sam@sam:~/fullstack-starknet/part1/starknet$ tree
.
├── accounts.json
├── contracts
│ └── contract.cairo # A "hello world" contract with basic storage, getters, and setters
├── Makefile
└── tests
└── test_contract.py
3 directories, 5 files
Our last command reccommended running nile compile
, this can also be done with the makefile:
(env) sam@sam:~/fullstack-starknet/part1/starknet$ make build
nile compile
🤖 Compiling all Cairo contracts in the contracts directory
🔨 Compiling contracts/contract.cairo
✅ Done
The unit testing setup can also be run similarly:
(env) sam@sam:~/fullstack-starknet/part1/starknet$ make test
pytest tests/
====================================== test session starts ======================================
platform linux -- Python 3.7.12, pytest-6.2.5, py-1.11.0, pluggy-1.0.0
rootdir: /home/sam/fullstack-starknet/part1/starknet
plugins: typeguard-2.13.3, asyncio-0.17.0, web3-5.26.0
collected 1 item
tests/test_contract.py . [100%]
====================================== 1 passed, 6 warnings in 5.19s ======================================
If you haven't already, check out the contract code initialized by Nile. It's a simple counter with storage, an external modifier, and a view. An in depth explanation can be found here in the official docs. Once that's starting to make sense, this upcoming section won't be too big of a leap.
If you ever find yourself needing a refresher on syntax or other core concepts, Perama's blog is turning into an invaluable resource on Cairo.
All contracts will start with a section like this.
# The "%lang" directive declares this code as a StarkNet contract.
%lang starknet
from starkware.cairo.common.cairo_builtins import HashBuiltin, SignatureBuiltin
from starkware.cairo.common.hash import hash2
from starkware.cairo.common.math import assert_not_zero
from starkware.cairo.common.signature import verify_ecdsa_signature
from starkware.starknet.common.syscalls import get_tx_signature
Coming from other programming languages, this should all be pretty familiar.
Now we'll need a place to store our data, specifically:
The first two are super straight forward – simple mappings from a vehicle ID to public key.
#
# Storage
#
# Who owns/controls the car (can update signing authority)
@storage_var
func vehicle_owner_public_key(vehicle_id : felt) -> (public_key : felt):
end
# Who signs commitments on behalf of the car
@storage_var
func vehicle_signer_public_key(vehicle_id : felt) -> (public_key : felt):
end
The third however is a little more involved. While we could do another simple mapping from vehicle ID to state hash, this would only allow us to store one hash on chain and overwrite it.
Instead, we'll need to add a unique "nonce" to be used as an index for transactions performed on the vehicle. Similar to Ethereum transaction nonces, these will start at 0 and increment with every signed operation made for the vehicle.
# Hashes for vehicle state at nonce
@storage_var
func vehicle_state(vehicle_id : felt, nonce : felt) -> (state_hash : felt):
end
Most importantly, a nonce also helps protect against replay attacks – where a malicious observer can resubmit a signed transaction and modify the state without the owner/signer's keys. By requiring that a new & unique nonce be included in the signed payload, we can be sure that a signature produced now and published on chain will not be able to be useful to attackers in the future.
In order to verify our transactions later, we'll need a way to keep track of the current expected nonce.
✨ Exercise: try to implement another storage variable
vehicle_nonce()
that maps fromvehicle_id
tononce
(answer hidden below)
# Stores the nonce expected for the next transaction (including key management & state commitments)
@storage_var
func vehicle_nonce(vehicle_id : felt) -> (nonce : felt):
end
Easy enough?
We've got a place to store data, now we'll need some functions to access it. Our getters will be annotated with @view
– meaning that the method only queries the state without modifying it.
⚠ WARNING: In the current version, this is NOT enforced by the compiler. Be sure to check that @view functions don't modify state.
#
# Getters
#
@view
func get_owner{syscall_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr}(
vehicle_id : felt) -> (public_key : felt):
let (public_key) = vehicle_owner_public_key.read(vehicle_id=vehicle_id)
return (public_key=public_key)
end
@view
func get_signer{syscall_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr}(
vehicle_id : felt) -> (public_key : felt):
let (public_key) = vehicle_signer_public_key.read(vehicle_id=vehicle_id)
return (public_key=public_key)
end
@view
func get_state{syscall_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr}(
vehicle_id : felt, nonce: felt) -> (state_hash : felt):
let (state_hash) = vehicle_state.read(vehicle_id=vehicle_id, nonce=nonce)
return (state_hash=state_hash)
end
In each of the above methods, we take in a vehicle ID, read directly from storage, and return the value found.
The implicit arguments in { curly braces }
might feel a little strange. Another excerpt from the official documentation explains them in a bit more detail:
Consider the implicit arguments:
syscall_ptr
,pedersen_ptr
andrange_check_ptr
:
- You should be familiar with
pedersen_ptr
, which allows to compute the Pedersen hash function, andrange_check_ptr
, which allows to compare integers. But it seems that the contract doesn’t use any hash function or integer comparison, so why are they needed? The reason is that storage variables require these implicit arguments in order to compute the actual memory address of this variable. This may not be needed in simple variables, but with maps (see Storage maps) computing the Pedersen hash is part of whatread()
andwrite()
do.syscall_ptr
is a new primitive, unique to StarkNet contracts (it doesn’t exist in Cairo).syscall_ptr
allows the code to invoke system calls. It is also implicit arguments ofread()
andwrite()
(this time, because storage access is done using system calls).
They may still feel weird and annoying, but without them, these variables would need to be passed around everywhere.
Before we move onto modifying contract state, take a shot at implementing a function to fetch a vehicle's current nonce. We'll need to query this in the next section.
✨ Exercise: try to implement another view
get_nonce()
to read & return thenonce
for a givenvehicle_id
(answer hidden below)
Something like this?
@view
func get_nonce{syscall_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr}(
vehicle_id : felt) -> (nonce : felt):
let (nonce) = vehicle_nonce.read(vehicle_id=vehicle_id)
return (nonce=nonce)
end
Remember the implicit arguments (i.e. syscall_ptr, pedersen_ptr, range_check_ptr
) are required for functions reading from or writing to storage.
Time to tie it all together with @external
functions that modify contract state. We're going to need the following actions:
register_vehicle
– assign a vehicle ID to an owner and signer (public keys, potentially the same one)attest_state
– a function for a vehicle's authorized signer (our raspberry pi) to make commitments about the car's stateset_signer
– a management function allowing the vehicle owner to swap out the authorized signerStarting with vehicle registration, we'll make a function where anyone can register a car to a owner's public key.
For now, we'll keep this function unsigned. Can you think of scenarios where this would be desirable? Where it could be harmful? Perhaps a government entity would want only one authorized registration key?
#
# Setters
#
# Initializes the vehicle with a given owner & signer
@external
func register_vehicle{syscall_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr}(
vehicle_id : felt, owner_public_key : felt, signer_public_key : felt):
# Verify that the vehicle ID is available
let (is_vehicle_id_taken) = vehicle_owner_public_key.read(vehicle_id=vehicle_id)
assert is_vehicle_id_taken = 0
# In cairo, everything uninitialized will by default have a zero value. It is
# important to keep this in mind when designing your contracts, as it will be
# difficult to distinguish between a variable explicitly set to zero and one
# that simply hasn't been initialized yet.
# Initialize the vehicle's owner and signer
vehicle_owner_public_key.write(vehicle_id=vehicle_id, value=owner_public_key)
vehicle_signer_public_key.write(vehicle_id=vehicle_id, value=signer_public_key)
# Because this function isn't signed, there is no need to increment the vehicle nonce.
return ()
end
To test our functionality so far, let's modify the unit test suite at tests/test_contract.py
:
import pytest
import asyncio
from starkware.crypto.signature.signature import (
pedersen_hash,
private_to_stark_key,
sign,
)
from starkware.starknet.testing.starknet import Starknet
from starkware.starkware_utils.error_handling import StarkException
@pytest.fixture(scope="module")
def event_loop():
return asyncio.new_event_loop()
# Reusable local network & contract to save testing time
@pytest.fixture(scope="module")
async def contract_factory():
starknet = await Starknet.empty()
contract = await starknet.deploy("contracts/contract.cairo")
return starknet, contract
# Some mock keypairs to test with
some_vehicle = 1
some_owner_secret = 12345
some_owner = private_to_stark_key(some_owner_secret)
some_signer_secret = 123456789
some_signer = private_to_stark_key(some_signer_secret)
some_other_signer_secret = 9876754321
some_other_signer = private_to_stark_key(some_other_signer_secret)
# The testing library uses python's asyncio. So the following
# decorator and the ``async`` keyword are needed.
@pytest.mark.asyncio
async def test_register_vehicle(contract_factory):
"""Should register a vehicle to a given public key"""
_, contract = contract_factory
await contract.register_vehicle(
vehicle_id=some_vehicle,
owner_public_key=some_owner,
signer_public_key=some_signer,
).invoke()
# Check the owner is registered
registrant = await contract.get_owner(vehicle_id=some_vehicle).call()
assert registrant.result == (some_owner,)
# ... and the signer
signer = await contract.get_signer(vehicle_id=some_vehicle).call()
assert signer.result == (some_signer,)
Running make test
should kick off a pytest session & voila ✨
=============================== test session starts ===============================
platform linux -- Python 3.7.12, pytest-6.2.5, py-1.11.0, pluggy-1.0.0
rootdir: /home/sam/fullstack-starknet/part1/starknet
plugins: asyncio-0.17.1, typeguard-2.13.3, web3-5.26.0
asyncio: mode=legacy
collected 1 items / 0 deselected / 1 selected
tests/test_contract.py . [100%]
================== 1 passed, 0 deselected, 0 warnings in 15.75s ===================
Now that a vehicle can be registered, we also need a way for authorized signers to make commitments about the current vehicle state. For this, we'll need a function that takes in a vehicle ID, a nonce, and a hash of the current vehicle state.
We'll also need to ensure this transaction is signed, so all TXs with invalid signatures will be reverted & unable to modify contract state. From the official docs on transaction signing:
While we could add the signature to the transaction calldata (that is, add it as additional arguments to the smart contract function) [as seen in the StarkNet voting workshop], StarkNet has a special mechanism for handling transaction signatures, freeing the developer from including them in the transaction calldata. This does not mean that you must use a specific signature scheme, just that the signature data may be kept separately from the calldata. The system call function
get_tx_signature()
returns the length and data of the signature supplied with the transaction. It is up to the contract author to check that the signature is valid. Note that this function requires thesyscall_ptr
implicit argument.
Because ECDSA signatures work efficiently with STARKs and are natively supported on StarkNet, we'll use those.
# Vehicle signers can attest to a state hash -- data storage & verification off-chain
@external
func attest_state{
syscall_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr,
ecdsa_ptr : SignatureBuiltin*}(vehicle_id : felt, nonce : felt, state_hash : felt):
# Note the addition of an ecdsa_ptr implicit argument, this is required in functions
# that verify ECDSA signatures.
# Verify the vehicle has been registered with a signer
let (signer_public_key) = vehicle_signer_public_key.read(vehicle_id=vehicle_id)
assert_not_zero(signer_public_key)
# Make sure the current nonce was used
let (expected_nonce) = vehicle_nonce.read(vehicle_id=vehicle_id)
assert expected_nonce - nonce = 0
# Expected Signed Message = H( nonce + H( vehicle_id , H( signer_public_key ) ) )
let (h1) = hash2{hash_ptr=pedersen_ptr}(state_hash, 0)
let (h2) = hash2{hash_ptr=pedersen_ptr}(vehicle_id, h1)
let (message_hash) = hash2{hash_ptr=pedersen_ptr}(nonce, h2)
# Verify signature is valid and covers the expected signed message
let (sig_len : felt, sig : felt*) = get_tx_signature()
assert sig_len = 2 # ECDSA signatures have two parts, r and s
verify_ecdsa_signature(
message=message_hash, public_key=signer_public_key, signature_r=sig[0], signature_s=sig[1])
# If the contract passes ^this line, the signaure verification passed.
# Otherwise, the execution would halt and the transaction would revert.
# Register state & increment nonce
vehicle_state.write(vehicle_id=vehicle_id, nonce=nonce, value=state_hash)
vehicle_nonce.write(vehicle_id=vehicle_id, value=nonce + 1)
return ()
end
Building on our unit suite, lets test some invalid cases as well as the happy path:
@pytest.mark.asyncio
async def test_attest_state_invalid_nonce(contract_factory):
"""Should fail with invalid nonce"""
_, contract = contract_factory
state_hash = 1234
nonce = 666
message_hash = pedersen_hash(
nonce, pedersen_hash(some_vehicle, pedersen_hash(state_hash, 0))
)
sig_r, sig_s = sign(msg_hash=message_hash, priv_key=some_signer_secret)
with pytest.raises(StarkException):
await contract.attest_state(
vehicle_id=some_vehicle,
nonce=nonce,
state_hash=state_hash,
).invoke(signature=[sig_r, sig_s])
@pytest.mark.asyncio
async def test_attest_state_invalid_signature(contract_factory):
"""Should fail with invalid nonce"""
_, contract = contract_factory
with pytest.raises(StarkException):
await contract.attest_state(
vehicle_id=some_vehicle,
nonce=0,
state_hash=1234,
).invoke(signature=[123456789, 987654321])
@pytest.mark.asyncio
async def test_attest_state(contract_factory):
"""Should successfully attest to a state hash & increment nonce"""
_, contract = contract_factory
state_hash = 1234
nonce = 0
message_hash = pedersen_hash(
nonce, pedersen_hash(some_vehicle, pedersen_hash(state_hash, 0))
)
sig_r, sig_s = sign(msg_hash=message_hash, priv_key=some_signer_secret)
await contract.attest_state(
vehicle_id=some_vehicle,
nonce=nonce,
state_hash=state_hash,
).invoke(signature=[sig_r, sig_s])
# Check the nonce was incremented
new_nonce = await contract.get_nonce(vehicle_id=some_vehicle).call()
assert new_nonce.result == (nonce + 1,)
There's one last case that our function could fail for. Can you spot it?
(anwer hidden)
A commitment sent for an unregistered vehicle!
✨ Exercise: Implement one last test to cover this edge case
(anwer hidden)
@pytest.mark.asyncio
async def test_attest_state_unregistered_vehicle(contract_factory):
"""Should fail with an unregistered vehicle"""
_, contract = contract_factory
state_hash = 1234
nonce = 0
some_unregistered_vehicle = 5
message_hash = pedersen_hash(
nonce, pedersen_hash(some_vehicle, pedersen_hash(state_hash, 0))
)
sig_r, sig_s = sign(msg_hash=message_hash, priv_key=some_signer_secret)
with pytest.raises(StarkException):
await contract.attest_state(
vehicle_id=some_unregistered_vehicle,
nonce=nonce,
state_hash=state_hash,
).invoke(signature=[sig_r, sig_s])
What happens if the vehicle owner's raspberry pi breaks or the signer key is compromised? We'll need to implement a set_signer()
function to allow vehicle owners to update the signer public key
✨ Exercise: using the
attest_state
function as a guide, write the functionality that let's an owner swap out the current signer
(answer hidden below)
# Vehicle owners can change the signing authority for a car they own
@external
func set_signer{
syscall_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr,
ecdsa_ptr : SignatureBuiltin*}(vehicle_id : felt, nonce : felt, signer_public_key : felt):
# Verify the vehicle has been registered with an owner
let (owner_public_key) = vehicle_owner_public_key.read(vehicle_id=vehicle_id)
assert_not_zero(owner_public_key)
# Make sure the current nonce was used
let (expected_nonce) = vehicle_nonce.read(vehicle_id=vehicle_id)
assert expected_nonce - nonce = 0
# Verify signature
# Signed Message = H( nonce + H( vehicle_id , H( signer_public_key ) ) )
let (h1) = hash2{hash_ptr=pedersen_ptr}(signer_public_key, 0)
let (h2) = hash2{hash_ptr=pedersen_ptr}(vehicle_id, h1)
let (message_hash) = hash2{hash_ptr=pedersen_ptr}(nonce, h2)
let (sig_len : felt, sig : felt*) = get_tx_signature()
assert sig_len = 2
verify_ecdsa_signature(
message=message_hash, public_key=owner_public_key, signature_r=sig[0], signature_s=sig[1])
# Update signer & increment nonce
vehicle_signer_public_key.write(vehicle_id=vehicle_id, value=signer_public_key)
vehicle_nonce.write(vehicle_id=vehicle_id, value=nonce + 1)
return ()
end
Are you able to create some test cases for its behavior?
(answer hidden below)
@pytest.mark.asyncio
async def test_set_signer_invalid_nonce(contract_factory):
"""Should fail to update the signer with a bad nonce"""
_, contract = contract_factory
nonce = 666
message_hash = pedersen_hash(
nonce, pedersen_hash(some_vehicle, pedersen_hash(some_other_signer, 0))
)
sig_r, sig_s = sign(msg_hash=message_hash, priv_key=some_owner_secret)
with pytest.raises(StarkException):
await contract.set_signer(
vehicle_id=some_vehicle,
nonce=nonce,
signer_public_key=some_other_signer,
).invoke(signature=[sig_r, sig_s])
@pytest.mark.asyncio
async def test_set_signer_not_owner(contract_factory):
"""Should fail to update the signer if owner didn't sign the message"""
_, contract = contract_factory
nonce = await contract.get_nonce(vehicle_id=some_vehicle).call()
nonce = nonce.result[0]
message_hash = pedersen_hash(
nonce, pedersen_hash(some_vehicle, pedersen_hash(some_other_signer, 0))
)
# Error here: signing with vehicle signer, not owner
sig_r, sig_s = sign(msg_hash=message_hash, priv_key=some_signer_secret)
with pytest.raises(StarkException):
await contract.set_signer(
vehicle_id=some_vehicle,
nonce=nonce,
signer_public_key=some_other_signer,
).invoke(signature=[sig_r, sig_s])
@pytest.mark.asyncio
async def test_set_signer(contract_factory):
"""Should successfully update the signer for the car"""
_, contract = contract_factory
nonce = await contract.get_nonce(vehicle_id=some_vehicle).call()
nonce = nonce.result[0]
message_hash = pedersen_hash(
nonce, pedersen_hash(some_vehicle, pedersen_hash(some_other_signer, 0))
)
sig_r, sig_s = sign(msg_hash=message_hash, priv_key=some_owner_secret)
await contract.set_signer(
vehicle_id=some_vehicle,
nonce=nonce,
signer_public_key=some_other_signer,
).invoke(signature=[sig_r, sig_s])
# Check that the signer is updated
new_signer = await contract.get_signer(vehicle_id=some_vehicle).call()
assert new_signer.result == (some_other_signer,)
# ... and the nonce was incremented
new_nonce = await contract.get_nonce(vehicle_id=some_vehicle).call()
assert new_nonce.result == (nonce + 1,)
Fortunately, nile also provides some nice utilities for deploying our contracts too:
(env) sam@sam:~/fullstack-starknet/part1/starknet$ nile deploy contract --alias blackbox --network=goerli
🚀 Deploying contract
⏳ ️Deployment of contract successfully sent at 0x029af160331cb2c5898999034f51f3357243a36b93c7b696f7daf0711482458e
🧾 Transaction hash: 0x2fe91a7e15d1aa9890f7feabbae25864252a4a6fd0ef042e35250f71354073a
📦 Registering deployment as blackbox in goerli.deployments.txt
In the example above, we deploy artifacts/contracts.cairo
to the goerli testnet and alias that deployment to blackbox
so that nile can interact with it later.
Wait 5 minutes or so for the next testnet block to be produced (UX around pending transactions still being developed) and find your transaction in the explorer.
The deployed contract's page also provides a basic GUI out-of-the-box, similar to Etherscan.
Here, our @views
into the contract state: #readContract
… and our @external
setters: #writeContract
Congrats! You've now written & deployed a contract with storage, getters/setters, & a basic access control scheme! 🎊🎉
While you could interact with your contract from the cli now, it may be better to wait for the next part in the series, where we call the contract from our python code elsewhere in the application stack. This will make signed transactions easier to create and submit.