# Full-Stack Starknet * **[Part 1]** 🚧 [Getting Started in Cairo & Deploying with Nile](https://hackmd.io/@sambarnes/BJvGs0JpK) (***you are here***) * **[Part 2]** 🐍 [Contract Interaction with starknet.py](https://hackmd.io/@sambarnes/H1Fx7OMaF) * **[Part 3]** πŸ‘₯ [StarkNet Account Abstraction & Using Standard Contracts](https://hackmd.io/@sambarnes/rkGekNvAY) * **[Part 4]** πŸ’½ [Local Devnet & Starknet.py's Account Capabilities](https://hackmd.io/@sambarnes/By7kitOCt) * **[Part 5]** 🎨 [StarkNet Frontends w/ Cairopal & Argent X](https://hackmd.io/@sambarnes/HydPlH9CY) * **[Notes]** πŸ’° [Contract Costs & Why Our Design Needs Work](https://hackmd.io/@sambarnes/SkxMZHhRK) *Completed code on GitHub [here](https://github.com/sambarnes/fullstack-starknet). 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](https://docs.swmansion.com/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](https://en.wikipedia.org/wiki/On-board_diagnostics) 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"](https://en.wikipedia.org/wiki/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 ## Design ![Design diagram](https://i.imgur.com/i0ZFjfO.png) > 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. ## StarkNet Project Setup First, we'll need to [initialize project with Nile](https://medium.com/@martriay/manage-your-starknet-deployments-with-nile-%EF%B8%8F-e849d40546dd). 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](https://perama-v.github.io/cairo/pytest) 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](https://www.cairo-lang.org/docs/hello_starknet/intro.html#your-first-contract). 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](https://perama-v.github.io/cairo/by-example/) is turning into an invaluable resource on Cairo. ## A First Look at Cairo ### Directives & Imports All contracts will start with a section like this. ```python # 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. ### Storage Now we'll need a place to store our data, specifically: * what public key owns a given car * what public key the owner has granted signing authority * hashes of the vehicle's diagnotic data The first two are super straight forward -- simple mappings from a vehicle ID to public key. ```python= # # 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. ```python= # 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](https://en.wikipedia.org/wiki/Replay_attack) -- 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 from `vehicle_id` to `nonce` *(answer hidden below)* :::spoiler ```python= # 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? ::: ### Getters 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. ```python= # # 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](https://www.cairo-lang.org/docs/hello_starknet/intro.html#your-first-contract) explains them in a bit more detail: > Consider the implicit arguments: `syscall_ptr`, `pedersen_ptr` and `range_check_ptr`: > * You should be familiar with `pedersen_ptr`, which allows to compute the Pedersen hash function, and `range_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](https://www.cairo-lang.org/docs/hello_starknet/user_auth.html#storage-maps)) computing the Pedersen hash is part of what `read()` and `write()` 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 of `read()` and `write()` (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 the `nonce` for a given `vehicle_id` *(answer hidden below)* :::spoiler Something like this? ```python= @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. ::: ### Setters 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 state * `set_signer` -- a management function allowing the vehicle owner to swap out the authorized signer #### Vehicle Registration Starting 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? ```python= # # 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`: ```python= 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 =================== ``` #### State Commitments (Transaction Signatures) 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](https://www.cairo-lang.org/docs/hello_starknet/user_auth.html): > 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](https://www.youtube.com/watch?v=fpwSdNnzulM)], 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 the `syscall_ptr` implicit argument. Because ECDSA signatures work efficiently with STARKs and are natively supported on StarkNet, we'll use those. ```python= # 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: * TXs signed with an invalid nonce should fail * TXs with invalid signatures should fail * with everything correct & verified, the TX should succeed ```python= @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)* :::spoiler A commitment sent for an unregistered vehicle! ::: --- > ✨ Exercise: Implement one last test to cover this edge case *(anwer hidden)* :::spoiler ```python= @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]) ``` ::: #### Signing Authority Management 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)* :::spoiler ```python= # 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)* :::spoiler ```python= @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,) ``` ::: ## Testnet Deployment 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](https://goerli.voyager.online/tx/0x2fe91a7e15d1aa9890f7feabbae25864252a4a6fd0ef042e35250f71354073a). The [deployed contract](https://goerli.voyager.online/contract/0x029af160331cb2c5898999034f51f3357243a36b93c7b696f7daf0711482458e)'s page also provides a basic GUI out-of-the-box, similar to Etherscan. Here, our `@views` into the contract state: [#readContract](https://goerli.voyager.online/contract/0x029af160331cb2c5898999034f51f3357243a36b93c7b696f7daf0711482458e#readContract) ... and our `@external` setters: [#writeContract](https://goerli.voyager.online/contract/0x029af160331cb2c5898999034f51f3357243a36b93c7b696f7daf0711482458e#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](https://github.com/OpenZeppelin/nile#call-and-invoke) from the cli now, it may be better to wait for the [next part in the series](https://hackmd.io/@sambarnes/H1Fx7OMaF), where we call the contract from our python code elsewhere in the application stack. This will make signed transactions easier to create and submit.