Try   HackMD

Local Devnet & Starknet.py's Account Capabilities

Full-Stack Starknet:

Completed code on GitHub here.

If you remember back in part 2, we waited a while for contracts to be deployed & transactions to be processed during our testing. Although the public testnet is nice for retaining state across manual or end to end tests, it's not the best experience while quickly iterating on a project.

Using a Local Devnet

Instead of working with the testnet and it's longer block times, let's use another tool to speed things up. A local devnet server actually comes installed with nile.

(venv) sam@sam:~/fullstack-starknet/part4/starknet$ nile node
 * Running on http://localhost:5000/ (Press CTRL+C to quit)

This will be our own sandbox to build and transact in.

Let's also create a .env file to keep our private key & other configuration in:

#!/bin/bash

export NETWORK=localhost
export VEHICLE_ID=1
export SIGNER_SECRET=12345
export SIGNER_ADDRESS=
export CONTRACT_ADDRESS=

πŸ’‘ NOTE: running source .env will bring these variables into scope for your session

Turns out, nile can also interact with our devnet. To deploy a new account contract, we can run the following:

(venv) sam@sam:~/fullstack-starknet/part4/starknet$ nile setup SIGNER_SECRET
πŸš€ Deploying Account
⏳ ️Deployment of Account successfully sent at 0x020f85039a06ae18f071c8f781613e5461cdea07b9a3ff87c33d2e6f525e5b45
🧾 Transaction hash: 0x02bd1b29252d83c83daa87f583f11709174814bb63099379dc966d9fba654d12
πŸ“¦ Registering deployment as account-0 in localhost.deployments.txt

The setup command reads the private key SIGNER_SECRET from the environment variable we exported earlier – $SIGNER_SECRET – and deploys an account around that private key.

To compile & deploy our newest blackbox contract:

(venv) sam@sam:~/fullstack-starknet/part4/starknet$ nile compile
πŸ€– Compiling all Cairo contracts in the contracts directory
πŸ”¨ Compiling contracts/IAccount.cairo
πŸ”¨ Compiling contracts/ERC165_base.cairo
πŸ”¨ Compiling contracts/Initializable.cairo
πŸ”¨ Compiling contracts/Account.cairo
πŸ”¨ Compiling contracts/contract.cairo
πŸ”¨ Compiling contracts/utils/safemath.cairo
πŸ”¨ Compiling contracts/utils/constants.cairo
βœ… Done

# Targetting localhost and assigning a contract alias to our deployment
(venv) sam@sam:~/fullstack-starknet/part4/starknet$ nile deploy --network=localhost contract --alias blackbox
πŸš€ Deploying contract
⏳ ️Deployment of contract successfully sent at 0x019ffaa269233a233a238ff00fe74ef377aa2b34688591e5115162485f57a86f
🧾 Transaction hash: 0x008a0170df3e1f8e3a7787c7c797e6be814e8afad0ebd70d3b578c49f8b512c9
πŸ“¦ Registering deployment as blackbox in localhost.deployments.txt

We'll take the deployment addresses in the output, and update the .env file accordingly:

# ...previous stuff
export SIGNER_ADDRESS=0x020f85039a06ae18f071c8f781613e5461cdea07b9a3ff87c33d2e6f525e5b45
export CONTRACT_ADDRESS=0x019ffaa269233a233a238ff00fe74ef377aa2b34688591e5115162485f57a86f

We've now:

  • created a local development network
  • deployed a single account that will be our vehicle owner & signer (for simplicity)
  • deployed our blackbox contract

Time to refactor our registration and commitment code in the application server

Application Server Config

Before we get to that, a quick housekeeping item. A new module app/config.py will help us easily load our environment variables without it being hardcoded into the app:

import pydantic class Config(pydantic.BaseSettings): contract_address: str = pydantic.Field( default=..., description="The hex address of the blackbox contract", ) vehicle_id: int = pydantic.Field( default=1, description="The vehicle ID to signing for", ) signer_secret: int = pydantic.Field( default=..., description="The private key to sign with", ) signer_address: str = pydantic.Field( default=..., description="The hex address of the account contract for the signer", )

Pydantic will automatically read & convert types for the environment variables listed in that class (case insensitive). This will just make it easier to keep our private keys and other configuration out of version control.

Accounts with Starknet.py

Alright, back to the part of the API that interacts with StarkNet.

Previously, our application had to construct the calldata, keep track of a nonce, hash, and sign it all. Sizable chunk of code. For what should be a super common task.

Luckily, the talented folks at software-mansion provide first class support for the OpenZeppelin Account contract. So by using standard account contracts over in our cairo project, we can immediately leverage some powerful helpers on the python side.

import pydantic from fastapi import FastAPI, HTTPException from starknet_py.contract import Contract from starknet_py.net.client import Client from starknet_py.net.account.account_client import AccountClient, KeyPair from starkware.python.utils import from_bytes from app.config import Config API_VERSION = "0.0.1" app = FastAPI() # Load env vars into config class config = Config() # Create a generic starknet client that connects to our local devnet network = "http://localhost:5000" chain_id = from_bytes(b"SN_LOCALHOST") starknet_client = Client(net=network, chain=chain_id) account_client = AccountClient( address=config.signer_address, key_pair=KeyPair.from_private_key(config.signer_secret), net=network, chain=chain_id, ) @app.get(path="/api") def api_info(): return {"version": API_VERSION, **config.dict(exclude={"signer_secret"})} @app.post(path="/api/register") async def register_vehicle(): """ Registers the car on chain, with identical owner and signer. Returns the transaction hash. """ # Contract constructor passes in the account_client. # This allows proxying transactions through the account, to the target contract. contract = await Contract.from_address( address=config.contract_address, client=account_client, ) # Reads can still be processed immediately, without an AccountClient (owner,) = await contract.functions["get_owner"].call(config.vehicle_id) if owner != 0: raise HTTPException(status_code=422, detail="Vehicle already registered") # For writes, the AccountClient allows us to modify the behavior of any `.invoke()` # such that the transaction is: # * signed by the account's private key # * proxied through the account's contract # * and finally executed at the targeted blackbox contract invocation = await contract.functions["register_vehicle"].invoke( vehicle_id=config.vehicle_id, owner_address=int(config.signer_address, 16), signer_address=int(config.signer_address, 16), ) await invocation.wait_for_acceptance() return {"tx_hash": invocation.hash}

In a new terminal window, within the pi application directory, we can test this out by running the application server:

sam@sam:~/fullstack-starknet/part4/pi$ poetry run task dev
INFO:     Will watch for changes in these directories: ['/home/sam/fullstack-starknet/part4/pi']
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO:     Started reloader process [9152] using statreload
INFO:     Started server process [9154]
INFO:     Waiting for application startup.
INFO:     Application startup complete.

Then back in our old terminal session for the contract, send a request to register our vehicle:

(venv) sam@sam:~/fullstack-starknet/part4/starknet$ http POST localhost:8000/api/register

HTTP/1.1 200 OK
{
    "tx_hash": "0x00ba970426cbaf455b9ed0b4158b4333d07637c1cff05789fb625a306656491a"
}

Looks like it went through!

Using nile, we can veify the transaction was processed by getting the current owner of the vehicle:

(venv) sam@sam:~/fullstack-starknet/part4/starknet$ nile call blackbox get_owner 1
0x20f85039a06ae18f071c8f781613e5461cdea07b9a3ff87c33d2e6f525e5b45

…it's our account contract's address!

✨ Exercise: Using the AccountClient pattern observed in the registration logic, are you able to refactor the bits that call attest_state in the POST /api/commit endpoint?

(answer hidden below)

Something along these lines would work well enough. Now letting us separate smart contract interactions and state_hash calculation (i.e. reading from OBD2, formatting & storing data, etc… the boring parts πŸ˜„).

class CommitRequest(pydantic.BaseModel):
    state_id: int
    state_hash: int


@app.post(path="/api/commit")
async def commit(req: CommitRequest):
    """Commits a single state attestation on chain, using the most recent window of data."""
    contract = await Contract.from_address(
        address=config.contract_address,
        client=account_client,
    )

    invocation = await contract.functions["attest_state"].invoke(
        vehicle_id=config.vehicle_id,
        state_id=req.state_id,
        state_hash=req.state_hash,
    )
    await invocation.wait_for_acceptance()
    return {"tx_hash": invocation.hash}

If you've implemented it correctly, hitting the route should now return something like this after creating the transaction.

(venv) sam@sam:~/fullstack-starknet/part4/starknet$ http POST localhost:8000/api/commit state_id=1 state_hash=424242

HTTP/1.1 200 OK
{
    "tx_hash": "0x0780adf2461b522fd2a30abebe784d05e84b9caf1e2709283322c964c1616382"
}

Subsequently, we can verify by reading back the state in the CLI. For car 1, at state_id 1, we see our state was updated correctly:

(venv) sam@sam:~/fullstack-starknet/part4/starknet$ nile call blackbox get_state 1 1
424242

https://www.youtube.com/watch?v=1Bix44C1EzY πŸŽŠπŸŽ‰