Overview
The Cartesi HLF is a framework developing DApps that run inside the Cartesi machine.
The main goals of the framework are:
- Pythonic: Offer a idiomatic way of writing the code and specifying the interactions.
- Easy to understand: Inspired on widely used web frameworks, and have a clear to use interface.
- Testability: Have test as a first class citizen, giving the developer tools to write tests for the DApps that run on your local Python environment.
- Flexibility: You’re free to take full control of the inputs and outputs for cases where the given high level tools are not enough.
Installation
To install the framework you just have to do a simple:
pip install python-cartesi
Although this is a pure Python library, it depends on PyCryptodome, which will need to compile some source code. You are advised to either include build-essential
in the apt-get install
command of your DApp’s Dockerfile or include the line --find-links https://prototyp3-dev.github.io/pip-wheels-riscv/wheels/
in the beginning of your requirements.txt file in order to use a pre-built binary for RiscV.
Getting Started
A very simple DApp that simply echoes in a notice whatever input is sent to it can be seen below:
import logging
from cartesi import DApp, Rollup, RollupData
LOGGER = logging.getLogger(__name__)
logging.basicConfig(level=logging.DEBUG)
dapp = DApp()
@dapp.advance()
def handle_advance(rollup: Rollup, data: RollupData) -> bool:
payload = data.str_payload()
LOGGER.debug("Echoing '%s'", payload)
rollup.notice("0x" + payload.encode('utf-8').hex())
return True
if __name__ == '__main__':
dapp.run()
The handle_advance function will be registered as the DApp’s default route of advance state requests by the dapp.advance()
decorator. The framework also supplies several routers that will offer you convenient ways of handling inputs of commonly used formats. These routers will be discussed in the Routers section.
The handler function also receives two inputs: an instance of a Rollup
object, that will allow you to interact with the Rollup Server, and an instance of RollupData
, that contains all the inputs and metadata for the current transaction.
Interacting with the Rollup Server
Every handler will receive an instance of the Rollup
class, that abstracts the communications with the Rollup Server. This class exposes three main methods:
Rollup.notice(self, payload: str)
Adds a new notice to the current advance-state request, by calling the Add new notice API. The payload
parameter should be in the Ethereum hex binary format, meaning that it should start with the '0x'
characters followed by the hex-encoded content.
Please note that notices only make sense to be called inside advance-state requests.
Rollup.report(self, payload: str)
Adds a new report to the current request, by calling the Add new report API. Just like the notice, the payload
parameter should be in the Ethereum hex binary format, meaning that it should start with the '0x'
characters followed by the hex-encoded content.
Reports can be added in both advance-state and inspect-state requests.
Rollup.voucher(self, payload: dict)
Adds a new voucher to the current request by calling the Add new voucher API. The payload
should be a voucher dict, containing the two following keys:
- destination: The address of the destination contract as a string, starting with the
'0x'
prefix. - payload: The payload for the transaction in Ethereum hex binary format. The contents of this field will be the transaction’s data field.
Routers
Routers simplify the coding experience by identifying the request type using common patterns in the input data, and calling your handler only when several conditions are met.
Once an input is received by either an advance-state or inspect request, the DApp will go through the list of registered handlers, find the first match and execute it. Each handler should return a boolean indicating whether the transaction was successful or not. If a handler for an advance state request returns false, the state of the DApp will be reverted to what it was before the transaction was received.
To use a router, it must be explicitly instantiated and added to the DApp. For example, to use a JSON Router, you should adapt your DApp code to include the add_router()
call, like the snippet below:
from cartesi import DApp, JSONRouter
# Create a DApp instance
dapp = DApp()
# Instantiate the JSON Router
json_router = JSONRouter()
# Register the JSON Router into the DApp
dapp.add_router(json_router)
JSON Router
The JSON Router will match with inputs whose contents meet two criteria:
- The entirety of the input’s content must be a valid JSON
- A pair of key/value must match with the pair given in the declaration of the route
The JSON Router expose two decorators methods: advance(route_dict)
and inspect(route_dict)
. The first matches with advance-state requests and the latter with inspects. Both will receive a dictionary that will be tested against the input.
For example, a route that handles the creation of a profile could be coded as below:
from cartesi import DApp, Rollup, RollupData, JSONRouter
dapp = DApp()
json_router = JSONRouter()
dapp.add_router(json_router)
@json_router.advance({"op": "create-profile"})
def handle_create_profile(rollup: Rollup, data: RollupData):
data = data.json_payload()
name = data['name']
rollup.report('0x' + name.encode('utf-8').hex())
return True
if __name__ == '__main__':
dapp.run()
For this DApp, if the data incoming from the Cartesi input is the equivalent to the JSON {"op": "create-profile", "name": "John Doe"}
, router will match due to the presence of the "op":"create-profile"
key-value pair, and the handler should generate a report containing the string “John Doe”.
ABI Router
The ABI Router is useful when the input resembles the Solidity ABI encoding. It offers several ways of matching with the incoming content:
- Header (optional): Matches with the input if it starts with a predefined sequence of bytes.
- Sender Address (optional): Matches with the input when the sender corresponds to a predefined address (only for advance-state requests).
Working with headers
To match with headers, you should pass an instance of a subclass of ABIHeader
to the header
parameter of the decorator. The framework supplies the following data classes:
ABILiteralHeader
Matches with a literal header supplied by the developer in the header
attribute, as bytes. For example, the following handler matches with inputs starting with the bytes 0x01020304
:
from cartesi import DApp, Rollup, RollupData, ABIRouter, ABILiteralHeader
dapp = DApp()
abi_router = ABIRouter()
dapp.add_router(abi_router)
@abi_router.advance(header=ABILiteralHeader(header=bytes.fromhex('01020304')))
def handle_input_1234(rollup: Rollup, data: RollupData):
...
ABIFunctionSelectorHeader
Generates a header according to the Solidity ABI Function Selector. It expects two attributes:
function
: The name of the function being calledargument_types
: A list of strings representing the Solidity types for each parameter.
With these arguments, the class will compute the four first bytes of the keccak-256 hash for the string <function>(<argument_types>)
, and use it as a header.
For example, the declaration ABIFunctionSelectorHeader(function='withdraw', argument_types=['uint256', 'address'])
will match with a message starting with the bytes 00f714ce
, as they are the first 4 bytes of the Keccak-256 of the string withdraw(uint256,address)
Creating a custom header
The header classes are Pydantic models that inherit from the ABIHeader
abstract base class. To create a custom header class, you should subclass ABIHeader
and implement the method to_bytes()
, as below:
from Crypto.Hash import keccak
from cartesi.models import ABIHeader
class MyCustomHeader(ABIHeader):
descriptor: str
def to_bytes(self) -> bytes:
sig_hash = keccak.new(digest_bits=256)
sig_hash.update(self.descriptor.encode('utf-8'))
header = sig_hash.digest()[:4]
return header
Matching with message sender
The msg_sender
parameter for the advance decorator method of the ABIRouter
will match not with the contents but with the sender of the message. For example, to match with the Cartesi’s Ether Portal, you can declare a route like the code below:
from cartesi import DApp, Rollup, RollupData, ABIRouter, ABILiteralHeader
dapp = DApp()
abi_router = ABIRouter()
dapp.add_router(abi_router)
ETHER_PORTAL = '0xffdbe43d4c855bf7e0f105c400a50857f53ab044'
@abi_router.advance(msg_sender=ETHER_PORTAL)
def handle_deposit(rollup: Rollup, data: RollupData):
...
In this example, the handle_deposit
function will be called whenever the Ether portal sends an input to the DApp.
Both the msg_sender
and header
parameters can be set at the same time. In this case, the message must match with both criteria to trigger the execution of the handler.
URL Router
The URLRouter is useful when the input is part of a URL. This can happen, for example, in a GET inspect request. The input is assumed to be the path portion of the URL, without the leading slash, and optionally followed by the query string part.
The router exposes two decorator methods: inspect(path_template)
and advance(path_template)
, for inspect and advance requests. Both methods receive a path template as parameter. This template can specify dynamic parts, such as path parameters, by surrounding it in curly braces. For example:
- A path template
'transactions/by-date'
will match both the inputtransactions/by-date
andtransactions/by-date?destination=abc123
- A path template
'wallet/{id}/balance'
will matchwallet/123/balance
, but notwallet//balance
The handler can receive a third argument of the URLParameter
type. This object will contain two attributes:
path_params
: a dict mapping the name of a path parameter to its value. For example, when the template is'wallet/{id}/balance'
and the input iswallet/123/balance
, the value ofpath_params
will be{'id': '123'}
. If the template doesn’t specify any dynamic part, the value of this attribute will be an empty dictionary.query_params
: a dict mapping the name of each query string parameters to a list of values. For example, if the matched input istransactions/by-date?destination=abc123
, the value for this attribute will be{'destination': ['abc123']}
. If no query string is passed, this attribute will be an empty dictionary.
[!IMPORTANT]
It is mandatory to correctly annotate the handler’s parameters with type hints. The URLHandler will use this information to dynamically determine what information to send to the handler.
The code fragment for the DApp below, for example, will return a report containing the string ‘Hello World’ when the user send an input hello/world
. When running with sunodo, this can be achieved by sending an HTTP GET request to http://localhost:8000/inspect/hello/world
.
from cartesi import DApp, Rollup, URLRouter, URLParameters
dapp = DApp()
url_router = URLRouter()
dapp.add_router(url_router)
@url_router.inspect('hello/{name}')
def hello_world_inspect_params(rollup: Rollup, params: URLParameters) -> bool:
msg = f'Hello {params.path_params["name"]}'
rollup.report('0x' + msg.encode('utf-8').hex())
return True
DApp Relay Router
This is a very simple router which will receive and accumulate the DApp’s contract address, as reported by the DApp address relay contact. The router itself only exposes an attribute called address
, that will be initialized as None and set to the address reported by the relay contract once it is received.
from cartesi import DApp
from cartesi.router import DAppAddressRouter
ADDRESS_RELAY_ADDRESS = '0xf5de34d6bbc0446e2a45719e718efebaae179dae'
dapp = DApp()
dapp_address = DAppAddressRouter(relay_address=ADDRESS_RELAY_ADDRESS)
dapp.add_router(dapp_address)
The DApp default Router
The DApp object itself exposes two decorators: advance()
and inspect()
. The handled decorated with these methods will be called if none of the available routes match. They act, therefore, as a default handler for each type of request. This can be used to both create more specific error handlers for your application, or to handle specific cases not covered by a generic router.
For example, given the following DApp:
from cartesi import DApp, Rollup, RollupData, JSONRouter
dapp = DApp()
json_router = JSONRouter()
dapp.add_router(json_router)
@json_router.advance({"op": "create-profile"})
def handle_create_profile(rollup: Rollup, data: RollupData):
data = data.json_payload()
name = data['name']
rollup.report('0x' + name.encode('utf-8').hex())
return True
@dapp.advance()
def default_handler(rollup: Rollup, data: RollupData):
rollup.report('0x' + 'Unknown Operation'.encode('utf-8').hex())
return True
if __name__ == '__main__':
dapp.run()
If the user passes an invalid JSON or a document that does not contain the "op":"create-profile"
key-value pair, the handle_create_profile
route will not match and the framework will call the default_handler
function with the input.
Testing
Testing is an important part of the development of complex software. The framework provides a TestClient that can be used to interact a DApp inside automated tests. The constructor of the TestClient
class expects a fully configured instance of the DApp
class, and expose methods for sending advance and inspect requests.
For example, supposing we have an echo.py
file with an implementation of an echo DApp, we could write the following file for automated tests using pytest:
from cartesi.testclient import TestClient
import pytest
import echo
@pytest.fixture
def dapp_client() -> TestClient:
client = TestClient(echo.dapp)
return client
def test_simple_echo(dapp_client: TestClient):
hex_payload = '0x' + 'hello'.encode('utf-8').hex()
dapp_client.send_advance(hex_payload=hex_payload)
assert dapp_client.rollup.status
assert len(dapp_client.rollup.notices) > 0
assert dapp_client.rollup.notices[-1]['data']['payload'] == hex_payload
Although the example above was written using pytest, the TestClient makes no assumption about the testing framework, so it should work equally well using the python’s builtin unittest module or other automated test frameworks.
The TestClient
exposes the following methods and attributes:
TestClient.send_advance(self, hex_payload, msg_sender, timestamp)
Sends an advance state input, such as one being received from the underlying blockchain input box. It expects the following parameters:
hex_payload
(required): a hex encoded string, starting with0x
, of the inputmsg_sender
(optional): a hex encoded string, starting with0x
of the address for the message sender. The default value for this parameter is0xdeadbeef7dc51b33c9a3e4a21ae053daa1872810
.
TestClient.send_inspect(self, hex_payload)
Sends an inspect input, such as one being received from the Inspect dApp state REST API. It only expects a hex encoded string, starting with 0x
, with the inspect payload.
For example, if you are running your DApp with sunodo, and want to simulate a the effects of a call to http://localhost:8000/inspect/hello/world
, the value that should be passed in the hex_payload is '0x68656c6c6f2f776f726c64'
, which is the hex encoded representation of hello/world
.
TestClient.rollup
This is an instance of a test double implementation of the rollup server. This object will contain attributes holding all the notices, reports and vouchers emitted by the DApp, together with the state of the last transaction. The individual attributes are listed below.
TestClient.rollup.status
The boolean status of the latest transaction as returned by the handler. A True
value indicates that the handler has completed successfully and its computation should be accepted. A False
value generally denotes an error or invalid input.
TestClient.rollup.notices
, TestClient.rollup.reports
, TestClient.rollup.vouchers
A list of dictionaries containing the emitted notices, reports or vouchers. Each dictionary contains the following keys:
epoch_index
: The epoch index for the input that generated this emitted outputinput_index
: The index of the input within the epoch that generated this outputdata
A dict containing the keypayload
, whose value is the payload for the corresponding output
For notices and reports, the payload will be a hex encoded string, starting with 0x
, of the contents of the notice or report. Vouchers, on the other hand, should be a dictionary as expected by the Rollups server Add new Voucher API, i.e., containing a destination
and a payload
keys.
Generating Vouchers
A voucher is an output that your DApp can generate to perform a transaction in the base layer blockchain. Once emitted, and finalized, the voucher can be retrieved by an external agent through the GraphQL API and then submitted to the DApp on-chain contract so that the desired transaction take place. Since it represents a full transaction, the voucher payload should be a full function call encoded according to the Solidity Contract ABI Specification.
This framework offers a pythonic way for representing the function calls and generating the voucher payload. The high level way of generating a voucher involves creating a Pydantic model with specially annotated type hints that will allow the encoder to understand which Solidity type should be used when encoding the values.
For example, let’s suppose we want to call an ERC20 transfer function, which has the following signature:
function transfer(address to, uint256 value)
A Pydantic model that represents the arguments of this function should look like:
from cartesi import abi
from pydantic import BaseModel
class TransferArgs(BaseModel):
to: abi.Address
value: abi.UInt256
Note that the attributes of our TransferArgs class is in the same order as the corresponding function.
To generate a voucher, we can create an instance of this class with the desired arguments and pass it to the create_voucher_from_model
function, as below:
from cartesi.vouchers import create_voucher_from_model
my_withdrawal = TransferArgs(to=receiver_address, value=value)
voucher = create_voucher_from_model(
destination=erc20_contract_address,
function_name='transfer',
args_model=my_withdrawal
)
The value returned by the create_voucher_from_model
function is a dict in the format expected by the voucher()
method of the Rollup
class, which is passed to each handler. See its description above for more information on the format.
A possible pattern that can simplify the development is to declare a function for generating a given voucher. Using the same use case, an example of this pattern would be:
from cartesi import abi
from cartesi.vouchers import create_voucher_from_model
from pydantic import BaseModel
class TransferArgs(BaseModel):
to: abi.Address
value: abi.UInt256
def transfer_erc20(
erc20_address: abi.Address,
receiver_address: abi.Address,
value=abi.UInt256
):
args = TransferArgs(to=erc20_address, value=value)
return create_voucher_from_model(
destination=erc20_contract_address,
function_name='transfer',
args_model=args,
)
This way, inside your handler you can simply call this transfer_erc20
function to have the corresponding voucher generated.
The cartesi.vouchers
module exposes two of such functions:
withdraw_ether(receiver, amount)
Generate a voucher for transferring Ethers from the contract to the receiver. The parameters are:
receiver
: Hex encoded address, starting with0x
, of the receiver of Ethersamount
: Amount of ethers to transfer
withdraw_erc20(rollup_address, token, receiver, amount)
Generate a voucher for transferring ERC20 tokens owned by the contract to a receiver. The parameters are
rollup_address
: The hex encoded address, starting with0x
, of the current DApp. See theDAppAddressRouter
above for a programmatic way of obtaining this value.token
: The hex encoded address, starting with0x
, of the ERC20 token contractreceiver
: The hex encoded address, starting with0x
, of the receiver of tokensamount
: Amount of tokens to transfer